diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 0b849afc0..d45c3dac0 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about.
Before submitting this PR, please make sure that:
-- [] You created a dedicated branch based on the `canary` branch.
-- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
-- [] You have tested this PR in your local instance.
+- [ ] You created a dedicated branch based on the `canary` branch.
+- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
+- [ ] You have tested this PR in your local instance.
## Issues related (if applicable)
diff --git a/.github/sponsors/awesome.png b/.github/sponsors/awesome.png
new file mode 100644
index 000000000..0753212ab
Binary files /dev/null and b/.github/sponsors/awesome.png differ
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 6c74dbc02..bfdc8c48b 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -20,6 +20,32 @@ jobs:
with:
node-version: 20.16.0
cache: "pnpm"
+
+ - name: Install Nixpacks
+ if: matrix.job == 'test'
+ run: |
+ export NIXPACKS_VERSION=1.41.0
+ curl -sSL https://nixpacks.com/install.sh | bash
+ echo "Nixpacks installed $NIXPACKS_VERSION"
+
+ - name: Install Railpack
+ if: matrix.job == 'test'
+ run: |
+ export RAILPACK_VERSION=0.15.4
+ curl -sSL https://railpack.com/install.sh | bash
+ echo "Railpack installed $RAILPACK_VERSION"
+
+ - name: Add build tools to PATH
+ if: matrix.job == 'test'
+ run: echo "$HOME/.local/bin" >> $GITHUB_PATH
+
+ - name: Initialize Docker Swarm
+ if: matrix.job == 'test'
+ run: |
+ docker swarm init
+ docker network create --driver overlay dokploy-network || true
+ echo "✅ Docker Swarm initialized"
+
- run: pnpm install --frozen-lockfile
- run: pnpm server:build
- run: pnpm ${{ matrix.job }}
diff --git a/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml
new file mode 100644
index 000000000..ddc51355a
--- /dev/null
+++ b/.github/workflows/sync-openapi-docs.yml
@@ -0,0 +1,70 @@
+name: Generate and Sync OpenAPI
+
+on:
+ push:
+ branches:
+ - canary
+ - main
+ paths:
+ - 'apps/dokploy/server/api/routers/**'
+ - 'packages/server/src/services/**'
+ - 'packages/server/src/db/schema/**'
+
+ workflow_dispatch:
+
+jobs:
+ generate-and-commit:
+ name: Generate OpenAPI and commit to Dokploy repo
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Dokploy repository
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20.16.0
+ cache: "pnpm"
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Generate OpenAPI specification
+ run: |
+ pnpm generate:openapi
+
+ # Verifica que se generó correctamente
+ if [ ! -f openapi.json ]; then
+ echo "❌ openapi.json not found"
+ exit 1
+ fi
+
+ echo "✅ OpenAPI specification generated successfully"
+
+ - name: Sync to website repository
+ run: |
+ # Clona el repositorio de website
+ git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
+
+ cd website-repo
+
+ # Copia el openapi.json al website (sobrescribe)
+ mkdir -p apps/docs/public
+ cp -f ../openapi.json apps/docs/public/openapi.json
+
+ # Configura git
+ git config user.name "Dokploy Bot"
+ git config user.email "bot@dokploy.com"
+
+ # Agrega y commitea siempre
+ git add apps/docs/public/openapi.json
+ git commit -m "chore: sync OpenAPI specification [skip ci]" \
+ -m "Source: ${{ github.repository }}@${{ github.sha }}" \
+ -m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
+ --allow-empty
+
+ git push
+
+ echo "✅ OpenAPI synced to website successfully"
+
diff --git a/.gitignore b/.gitignore
index 5e6e4eb3c..ab2fe76c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,8 @@ node_modules
.env.test.local
.env.production.local
+openapi.json
+
# Testing
coverage
@@ -41,4 +43,7 @@ yarn-error.log*
*.pem
-.db
\ No newline at end of file
+.db
+
+# Development environment
+.devcontainer
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 38a36345e..4c1f832db 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -148,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
```bash
# Install Buildpacks
-curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
+curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
```
## Pull Request
diff --git a/Dockerfile b/Dockerfile
index 11310b18e..5d7bb6770 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -46,23 +46,23 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules
# Install docker
-RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash
+RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash
-ARG NIXPACKS_VERSION=1.39.0
+ARG NIXPACKS_VERSION=1.41.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \
&& pnpm install -g tsx
# Install Railpack
-ARG RAILPACK_VERSION=0.2.2
+ARG RAILPACK_VERSION=0.15.4
RUN curl -sSL https://railpack.com/install.sh | bash
# Install buildpacks
-COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
+COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
CMD [ "pnpm", "start" ]
diff --git a/Dockerfile.cloud b/Dockerfile.cloud
index 8e4bac215..a0de32021 100644
--- a/Dockerfile.cloud
+++ b/Dockerfile.cloud
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
# Deploy only the dokploy app
-ARG NEXT_PUBLIC_UMAMI_HOST
-ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
+# ARG NEXT_PUBLIC_UMAMI_HOST
+# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
-ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
-ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
+# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
+# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
RUN pnpm install -g tsx
EXPOSE 3000
-CMD [ "pnpm", "start" ]
\ No newline at end of file
+CMD [ "pnpm", "start" ]
diff --git a/Dockerfile.schedule b/Dockerfile.schedule
index ecb125e09..ce1f96edf 100644
--- a/Dockerfile.schedule
+++ b/Dockerfile.schedule
@@ -35,4 +35,5 @@ COPY --from=build /prod/schedules/dist ./dist
COPY --from=build /prod/schedules/package.json ./package.json
COPY --from=build /prod/schedules/node_modules ./node_modules
-CMD HOSTNAME=0.0.0.0 && pnpm start
\ No newline at end of file
+ENV HOSTNAME=0.0.0.0
+CMD ["pnpm", "start"]
diff --git a/Dockerfile.server b/Dockerfile.server
index ea6b372e8..f5aa25c1e 100644
--- a/Dockerfile.server
+++ b/Dockerfile.server
@@ -35,4 +35,5 @@ COPY --from=build /prod/api/dist ./dist
COPY --from=build /prod/api/package.json ./package.json
COPY --from=build /prod/api/node_modules ./node_modules
-CMD HOSTNAME=0.0.0.0 && pnpm start
\ No newline at end of file
+ENV HOSTNAME=0.0.0.0
+CMD ["pnpm", "start"]
diff --git a/LICENSE.MD b/LICENSE.MD
index 6cbef2c6d..bcef8b36e 100644
--- a/LICENSE.MD
+++ b/LICENSE.MD
@@ -1,8 +1,13 @@
-# License
+Copyright 2026-present Dokploy Technology, Inc.
-## Core License (Apache License 2.0)
+Portions of this software are licensed as follows:
-Copyright 2025 Mauricio Siu.
+* All content that resides under a "/proprietary" directory of this repository, if that directory exists, is licensed under the license defined in "LICENSE_PROPRIETARY".
+* Content outside of the above mentioned directories or restrictions above is available under the "Apache License 2.0" license as defined below.
+
+## Apache License 2.0
+
+Copyright 2026-present Dokploy Technology, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,12 +20,4 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
-## Additional Terms for Specific Features
-The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
-
-- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
-- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
-- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
-
-For further inquiries or permissions, please contact us directly.
diff --git a/LICENSE_PROPRIETARY.md b/LICENSE_PROPRIETARY.md
new file mode 100644
index 000000000..0f4957575
--- /dev/null
+++ b/LICENSE_PROPRIETARY.md
@@ -0,0 +1,11 @@
+The Dokploy Source Available license (DSAL) version 1.0
+
+Copyright (c) 2026-present Dokploy Technology, Inc.
+
+With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software.
+
+This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
+
+For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component.
\ No newline at end of file
diff --git a/README.md b/README.md
index d60962cff..e97735597 100644
--- a/README.md
+++ b/README.md
@@ -68,51 +68,21 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
[Github Sponsors](https://github.com/sponsors/Siumauricio)
-
+## Sponsors
-
-
-### Hero Sponsors 🎖
-
-
-
-
-
-
-
-### Premium Supporters 🥇
-
-
-
-
-
-
-
-
-
-
-### Elite Contributors 🥈
-
-
-
-
-
-
-### Supporting Members 🥉
-
-
-
-
-
-
-
+| Sponsor | Logo | Supporter Level |
+|---------|:----:|----------------|
+| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | | 🎖 Hero Sponsor |
+| [LX Aer](https://www.lxaer.com/?ref=dokploy) | | 🎖 Hero Sponsor |
+| [LinkDR](https://linkdr.com/?ref=dokploy) | | 🎖 Hero Sponsor |
+| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | | 🎖 Hero Sponsor |
+| [Awesome Tools](https://awesome.tools/) | | 🎖 Hero Sponsor |
+| [Supafort](https://supafort.com/?ref=dokploy) | | 🥇 Premium Supporter |
+| [Agentdock](https://agentdock.ai/?ref=dokploy) | | 🥇 Premium Supporter |
+| [AmericanCloud](https://americancloud.com/?ref=dokploy) | | 🥈 Elite Contributor |
+| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | | 🥈 Elite Contributor |
+| [Cloudblast](https://cloudblast.io/?ref=dokploy) | | 🥉 Supporting Member |
+| [Synexa](https://synexa.ai/?ref=dokploy) | | 🥉 Supporting Member |
### Community Backers 🤝
diff --git a/apps/api/package.json b/apps/api/package.json
index dfc2a355d..0f4b1044f 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -13,7 +13,6 @@
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
- "@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.4.5",
"hono": "^4.7.10",
"pino": "9.4.0",
diff --git a/apps/api/src/schema.ts b/apps/api/src/schema.ts
index 5a4355956..e2f37cd1c 100644
--- a/apps/api/src/schema.ts
+++ b/apps/api/src/schema.ts
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
- type: z.enum(["deploy"]),
+ type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),
diff --git a/apps/api/src/utils.ts b/apps/api/src/utils.ts
index ee2ac3e50..13708006a 100644
--- a/apps/api/src/utils.ts
+++ b/apps/api/src/utils.ts
@@ -1,9 +1,10 @@
import {
- deployRemoteApplication,
- deployRemoteCompose,
- deployRemotePreviewApplication,
- rebuildRemoteApplication,
- rebuildRemoteCompose,
+ deployApplication,
+ deployCompose,
+ deployPreviewApplication,
+ rebuildApplication,
+ rebuildCompose,
+ rebuildPreviewApplication,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
@@ -16,13 +17,13 @@ export const deploy = async (job: DeployJob) => {
await updateApplicationStatus(job.applicationId, "running");
if (job.server) {
if (job.type === "redeploy") {
- await rebuildRemoteApplication({
+ await rebuildApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
- await deployRemoteApplication({
+ await deployApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -36,13 +37,13 @@ export const deploy = async (job: DeployJob) => {
if (job.server) {
if (job.type === "redeploy") {
- await rebuildRemoteCompose({
+ await rebuildCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
- await deployRemoteCompose({
+ await deployCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -54,8 +55,15 @@ export const deploy = async (job: DeployJob) => {
previewStatus: "running",
});
if (job.server) {
- if (job.type === "deploy") {
- await deployRemotePreviewApplication({
+ if (job.type === "redeploy") {
+ await rebuildPreviewApplication({
+ applicationId: job.applicationId,
+ titleLog: job.titleLog || "Rebuild Preview Deployment",
+ descriptionLog: job.descriptionLog || "",
+ previewDeploymentId: job.previewDeploymentId,
+ });
+ } else if (job.type === "deploy") {
+ await deployPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Preview Deployment",
descriptionLog: job.descriptionLog || "",
diff --git a/apps/dokploy/.env.example b/apps/dokploy/.env.example
index ba57ec7be..8f801196e 100644
--- a/apps/dokploy/.env.example
+++ b/apps/dokploy/.env.example
@@ -1,3 +1,3 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
-NODE_ENV=development
\ No newline at end of file
+NODE_ENV=development
diff --git a/apps/dokploy/.env.production.example b/apps/dokploy/.env.production.example
index 41e934c3a..560faf9e6 100644
--- a/apps/dokploy/.env.production.example
+++ b/apps/dokploy/.env.production.example
@@ -1,3 +1,2 @@
-DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
PORT=3000
NODE_ENV=production
\ No newline at end of file
diff --git a/apps/dokploy/__test__/cluster/upload.test.ts b/apps/dokploy/__test__/cluster/upload.test.ts
new file mode 100644
index 000000000..1ccb9e22d
--- /dev/null
+++ b/apps/dokploy/__test__/cluster/upload.test.ts
@@ -0,0 +1,243 @@
+import type { Registry } from "@dokploy/server";
+import { getRegistryTag } from "@dokploy/server";
+import { describe, expect, it } from "vitest";
+
+describe("getRegistryTag", () => {
+ // Helper to create a mock registry
+ const createMockRegistry = (overrides: Partial = {}): Registry => {
+ return {
+ registryId: "test-registry-id",
+ registryName: "Test Registry",
+ username: "myuser",
+ password: "test-password",
+ registryUrl: "docker.io",
+ registryType: "cloud",
+ imagePrefix: null,
+ createdAt: new Date().toISOString(),
+ organizationId: "test-org-id",
+ ...overrides,
+ };
+ };
+
+ describe("with username (no imagePrefix)", () => {
+ it("should handle simple image name without tag", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "nginx");
+ expect(result).toBe("docker.io/myuser/nginx");
+ });
+
+ it("should handle image name with tag", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "nginx:latest");
+ expect(result).toBe("docker.io/myuser/nginx:latest");
+ });
+
+ it("should handle image name with username already present (no duplication)", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "myuser/myprivaterepo");
+ // Should not duplicate username
+ expect(result).toBe("docker.io/myuser/myprivaterepo");
+ });
+
+ it("should handle image name with username and tag already present", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
+ // Should not duplicate username
+ expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
+ });
+
+ it("should handle complex image name with username", () => {
+ const registry = createMockRegistry({ username: "siumauricio" });
+ const result = getRegistryTag(
+ registry,
+ "siumauricio/app-parse-multi-byte-port-e32uh7",
+ );
+ // Should not duplicate username
+ expect(result).toBe(
+ "docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
+ );
+ });
+
+ it("should handle image name with different username (should not duplicate)", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "otheruser/myprivaterepo");
+ expect(result).toBe("docker.io/myuser/myprivaterepo");
+ });
+
+ it("should handle image name with full registry URL (no username)", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "docker.io/nginx");
+ // Should add username since imageName doesn't have one
+ expect(result).toBe("docker.io/myuser/nginx");
+ });
+
+ it("should handle image name with custom registry URL and username", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
+ // Should not duplicate username even if registry URL is different
+ expect(result).toBe("docker.io/myuser/repo");
+ });
+
+ it("should handle image name with custom registry URL (different username)", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
+ // Should use registry username, not the one in imageName
+ expect(result).toBe("docker.io/myuser/repo");
+ });
+ });
+
+ describe("with imagePrefix", () => {
+ it("should use imagePrefix instead of username", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ imagePrefix: "myorg",
+ });
+ const result = getRegistryTag(registry, "nginx");
+ expect(result).toBe("docker.io/myorg/nginx");
+ });
+
+ it("should use imagePrefix with image tag", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ imagePrefix: "myorg",
+ });
+ const result = getRegistryTag(registry, "nginx:latest");
+ expect(result).toBe("docker.io/myorg/nginx:latest");
+ });
+
+ it("should handle imagePrefix with username already in image name", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ imagePrefix: "myorg",
+ });
+ const result = getRegistryTag(registry, "myuser/myprivaterepo");
+ expect(result).toBe("docker.io/myorg/myprivaterepo");
+ });
+
+ it("should handle imagePrefix matching image name prefix", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ imagePrefix: "myorg",
+ });
+ const result = getRegistryTag(registry, "myorg/myprivaterepo");
+ // Should not duplicate prefix
+ expect(result).toBe("docker.io/myorg/myprivaterepo");
+ });
+ });
+
+ describe("without registryUrl", () => {
+ it("should work without registryUrl", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ registryUrl: "",
+ });
+ const result = getRegistryTag(registry, "nginx");
+ expect(result).toBe("myuser/nginx");
+ });
+
+ it("should work without registryUrl with imagePrefix", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ imagePrefix: "myorg",
+ registryUrl: "",
+ });
+ const result = getRegistryTag(registry, "nginx");
+ expect(result).toBe("myorg/nginx");
+ });
+
+ it("should handle username already present without registryUrl", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ registryUrl: "",
+ });
+ const result = getRegistryTag(registry, "myuser/myprivaterepo");
+ // Should not duplicate username
+ expect(result).toBe("myuser/myprivaterepo");
+ });
+ });
+
+ describe("with custom registryUrl", () => {
+ it("should handle custom registry URL", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ registryUrl: "ghcr.io",
+ });
+ const result = getRegistryTag(registry, "nginx");
+ expect(result).toBe("ghcr.io/myuser/nginx");
+ });
+
+ it("should handle custom registry URL with imagePrefix", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ imagePrefix: "myorg",
+ registryUrl: "ghcr.io",
+ });
+ const result = getRegistryTag(registry, "nginx");
+ expect(result).toBe("ghcr.io/myorg/nginx");
+ });
+
+ it("should handle custom registry URL with username already present", () => {
+ const registry = createMockRegistry({
+ username: "myuser",
+ registryUrl: "ghcr.io",
+ });
+ const result = getRegistryTag(registry, "myuser/myprivaterepo");
+ // Should not duplicate username
+ expect(result).toBe("ghcr.io/myuser/myprivaterepo");
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle empty image name", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "");
+ expect(result).toBe("docker.io/myuser/");
+ });
+
+ it("should handle image name with multiple slashes", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "org/suborg/repo");
+ expect(result).toBe("docker.io/myuser/repo");
+ });
+
+ it("should handle image name with username at different position", () => {
+ const registry = createMockRegistry({ username: "myuser" });
+ const result = getRegistryTag(registry, "org/myuser/repo");
+ expect(result).toBe("docker.io/myuser/repo");
+ });
+ });
+
+ describe("special characters in username", () => {
+ it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
+ const registry = createMockRegistry({
+ username: "robot$library+dokploy",
+ });
+ const result = getRegistryTag(registry, "nginx");
+ expect(result).toBe("docker.io/robot$library+dokploy/nginx");
+ });
+
+ it("should handle username with $ and other special characters", () => {
+ const registry = createMockRegistry({
+ username: "robot$test+app",
+ });
+ const result = getRegistryTag(registry, "myapp:latest");
+ expect(result).toBe("docker.io/robot$test+app/myapp:latest");
+ });
+
+ it("should handle username with multiple $ symbols", () => {
+ const registry = createMockRegistry({
+ username: "user$name$test",
+ });
+ const result = getRegistryTag(registry, "app");
+ expect(result).toBe("docker.io/user$name$test/app");
+ });
+
+ it("should handle username with + and - symbols", () => {
+ const registry = createMockRegistry({
+ username: "robot+test-user",
+ });
+ const result = getRegistryTag(registry, "nginx:latest");
+ expect(result).toBe("docker.io/robot+test-user/nginx:latest");
+ });
+ });
+});
diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
new file mode 100644
index 000000000..097c916ea
--- /dev/null
+++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
@@ -0,0 +1,215 @@
+import type { Domain } from "@dokploy/server";
+import { createDomainLabels } from "@dokploy/server";
+import { describe, expect, it } from "vitest";
+import { parse, stringify } from "yaml";
+
+/**
+ * Regression tests for Traefik Host rule label format.
+ *
+ * These tests verify that the Host rule is generated with the correct format:
+ * - Host(`domain.com`) - with opening and closing parentheses
+ * - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
+ *
+ * Issue: https://github.com/Dokploy/dokploy/issues/3161
+ * The bug caused Host rules to be malformed as Host`domain.com`)
+ * (missing opening parenthesis) which broke all domain routing.
+ */
+describe("Host rule format regression tests", () => {
+ const baseDomain: Domain = {
+ host: "example.com",
+ port: 8080,
+ https: false,
+ uniqueConfigKey: 1,
+ customCertResolver: null,
+ certificateType: "none",
+ applicationId: "",
+ composeId: "",
+ domainType: "compose",
+ serviceName: "test-app",
+ domainId: "",
+ path: "/",
+ createdAt: "",
+ previewDeploymentId: "",
+ internalPath: "/",
+ stripPath: false,
+ };
+
+ describe("Host rule format validation", () => {
+ it("should generate Host rule with correct parentheses format", async () => {
+ const labels = await createDomainLabels("test-app", baseDomain, "web");
+ const ruleLabel = labels.find((l) => l.includes(".rule="));
+
+ expect(ruleLabel).toBeDefined();
+ // Verify exact format: Host(`domain`)
+ expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
+ // Ensure opening parenthesis is present after Host
+ expect(ruleLabel).toContain("Host(`example.com`)");
+ // Ensure it does NOT have the malformed format
+ expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
+ });
+
+ it("should generate PathPrefix with correct parentheses format", async () => {
+ const labels = await createDomainLabels(
+ "test-app",
+ { ...baseDomain, path: "/api" },
+ "web",
+ );
+ const ruleLabel = labels.find((l) => l.includes(".rule="));
+
+ expect(ruleLabel).toBeDefined();
+ // Verify PathPrefix format
+ expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
+ expect(ruleLabel).toContain("PathPrefix(`/api`)");
+ // Ensure opening parenthesis is present
+ expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
+ });
+
+ it("should generate combined Host and PathPrefix with correct format", async () => {
+ const labels = await createDomainLabels(
+ "test-app",
+ { ...baseDomain, path: "/api/v1" },
+ "websecure",
+ );
+ const ruleLabel = labels.find((l) => l.includes(".rule="));
+
+ expect(ruleLabel).toBeDefined();
+ expect(ruleLabel).toBe(
+ "traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
+ );
+ });
+ });
+
+ describe("YAML serialization preserves Host rule format", () => {
+ it("should preserve Host rule format through YAML stringify/parse", async () => {
+ const labels = await createDomainLabels("test-app", baseDomain, "web");
+ const ruleLabel = labels.find((l) => l.includes(".rule="));
+
+ // Simulate compose file structure
+ const composeSpec = {
+ services: {
+ myapp: {
+ image: "nginx",
+ labels: labels,
+ },
+ },
+ };
+
+ // Stringify to YAML
+ const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
+
+ // Parse back
+ const parsed = parse(yamlOutput) as typeof composeSpec;
+ const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
+ l.includes(".rule="),
+ );
+
+ // Verify format is preserved
+ expect(parsedRuleLabel).toBe(ruleLabel);
+ expect(parsedRuleLabel).toContain("Host(`example.com`)");
+ expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
+ });
+
+ it("should preserve complex rule format through YAML serialization", async () => {
+ const labels = await createDomainLabels(
+ "test-app",
+ { ...baseDomain, path: "/api", https: true },
+ "websecure",
+ );
+
+ const composeSpec = {
+ services: {
+ myapp: {
+ labels: labels,
+ },
+ },
+ };
+
+ const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
+ const parsed = parse(yamlOutput) as typeof composeSpec;
+ const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
+ l.includes(".rule="),
+ );
+
+ expect(parsedRuleLabel).toContain(
+ "Host(`example.com`) && PathPrefix(`/api`)",
+ );
+ });
+ });
+
+ describe("Edge cases for domain names", () => {
+ const domainCases = [
+ { name: "simple domain", host: "example.com" },
+ { name: "subdomain", host: "app.example.com" },
+ { name: "deep subdomain", host: "api.v1.app.example.com" },
+ { name: "numeric domain", host: "123.example.com" },
+ { name: "hyphenated domain", host: "my-app.example-host.com" },
+ { name: "localhost", host: "localhost" },
+ { name: "IP address style", host: "192.168.1.100" },
+ ];
+
+ for (const { name, host } of domainCases) {
+ it(`should generate correct Host rule for ${name}: ${host}`, async () => {
+ const labels = await createDomainLabels(
+ "test-app",
+ { ...baseDomain, host },
+ "web",
+ );
+ const ruleLabel = labels.find((l) => l.includes(".rule="));
+
+ expect(ruleLabel).toBeDefined();
+ expect(ruleLabel).toContain(`Host(\`${host}\`)`);
+ // Verify parenthesis is present
+ expect(ruleLabel).toMatch(
+ new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
+ );
+ });
+ }
+ });
+
+ describe("Multiple domains scenario", () => {
+ it("should generate correct format for both web and websecure entrypoints", async () => {
+ const webLabels = await createDomainLabels("test-app", baseDomain, "web");
+ const websecureLabels = await createDomainLabels(
+ "test-app",
+ baseDomain,
+ "websecure",
+ );
+
+ const webRule = webLabels.find((l) => l.includes(".rule="));
+ const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
+
+ // Both should have correct format
+ expect(webRule).toContain("Host(`example.com`)");
+ expect(websecureRule).toContain("Host(`example.com`)");
+
+ // Neither should have malformed format
+ expect(webRule).not.toMatch(/Host`[^`]+`\)/);
+ expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
+ });
+ });
+
+ describe("Special characters in paths", () => {
+ const pathCases = [
+ { name: "simple path", path: "/api" },
+ { name: "nested path", path: "/api/v1/users" },
+ { name: "path with hyphen", path: "/api-v1" },
+ { name: "path with underscore", path: "/api_v1" },
+ ];
+
+ for (const { name, path } of pathCases) {
+ it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
+ const labels = await createDomainLabels(
+ "test-app",
+ { ...baseDomain, path },
+ "web",
+ );
+ const ruleLabel = labels.find((l) => l.includes(".rule="));
+
+ expect(ruleLabel).toBeDefined();
+ expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
+ // Verify parenthesis is present
+ expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
+ });
+ }
+ });
+});
diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts
new file mode 100644
index 000000000..be29748eb
--- /dev/null
+++ b/apps/dokploy/__test__/deploy/application.command.test.ts
@@ -0,0 +1,276 @@
+import * as adminService from "@dokploy/server/services/admin";
+import * as applicationService from "@dokploy/server/services/application";
+import { deployApplication } from "@dokploy/server/services/application";
+import * as deploymentService from "@dokploy/server/services/deployment";
+import * as builders from "@dokploy/server/utils/builders";
+import * as notifications from "@dokploy/server/utils/notifications/build-success";
+import * as execProcess from "@dokploy/server/utils/process/execAsync";
+import * as gitProvider from "@dokploy/server/utils/providers/git";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@dokploy/server/db", () => {
+ const createChainableMock = (): any => {
+ const chain = {
+ set: vi.fn(() => chain),
+ where: vi.fn(() => chain),
+ returning: vi.fn().mockResolvedValue([{}] as any),
+ } as any;
+ return chain;
+ };
+
+ return {
+ db: {
+ select: vi.fn(),
+ insert: vi.fn(),
+ update: vi.fn(() => createChainableMock()),
+ delete: vi.fn(),
+ query: {
+ applications: {
+ findFirst: vi.fn(),
+ },
+ },
+ },
+ };
+});
+
+vi.mock("@dokploy/server/services/application", async () => {
+ const actual = await vi.importActual<
+ typeof import("@dokploy/server/services/application")
+ >("@dokploy/server/services/application");
+ return {
+ ...actual,
+ findApplicationById: vi.fn(),
+ updateApplicationStatus: vi.fn(),
+ };
+});
+
+vi.mock("@dokploy/server/services/admin", () => ({
+ getDokployUrl: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/services/deployment", () => ({
+ createDeployment: vi.fn(),
+ updateDeploymentStatus: vi.fn(),
+ updateDeployment: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/providers/git", async () => {
+ const actual = await vi.importActual<
+ typeof import("@dokploy/server/utils/providers/git")
+ >("@dokploy/server/utils/providers/git");
+ return {
+ ...actual,
+ getGitCommitInfo: vi.fn(),
+ };
+});
+
+vi.mock("@dokploy/server/utils/process/execAsync", () => ({
+ execAsync: vi.fn(),
+ ExecError: class ExecError extends Error {},
+}));
+
+vi.mock("@dokploy/server/utils/builders", async () => {
+ const actual = await vi.importActual<
+ typeof import("@dokploy/server/utils/builders")
+ >("@dokploy/server/utils/builders");
+ return {
+ ...actual,
+ mechanizeDockerContainer: vi.fn(),
+ getBuildCommand: vi.fn(),
+ };
+});
+
+vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
+ sendBuildSuccessNotifications: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
+ sendBuildErrorNotifications: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/services/rollbacks", () => ({
+ createRollback: vi.fn(),
+}));
+
+import { db } from "@dokploy/server/db";
+import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
+
+const createMockApplication = (overrides = {}) => ({
+ applicationId: "test-app-id",
+ name: "Test App",
+ appName: "test-app",
+ sourceType: "git" as const,
+ customGitUrl: "https://github.com/Dokploy/examples.git",
+ customGitBranch: "main",
+ customGitSSHKeyId: null,
+ buildType: "nixpacks" as const,
+ buildPath: "/astro",
+ env: "NODE_ENV=production",
+ serverId: null,
+ rollbackActive: false,
+ enableSubmodules: false,
+ environmentId: "env-id",
+ environment: {
+ projectId: "project-id",
+ env: "",
+ name: "production",
+ project: {
+ name: "Test Project",
+ organizationId: "org-id",
+ env: "",
+ },
+ },
+ domains: [],
+ ...overrides,
+});
+
+const createMockDeployment = () => ({
+ deploymentId: "deployment-id",
+ logPath: "/tmp/test-deployment.log",
+});
+
+describe("deployApplication - Command Generation Tests", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(db.query.applications.findFirst).mockResolvedValue(
+ createMockApplication() as any,
+ );
+ vi.mocked(applicationService.findApplicationById).mockResolvedValue(
+ createMockApplication() as any,
+ );
+ vi.mocked(adminService.getDokployUrl).mockResolvedValue(
+ "http://localhost:3000",
+ );
+ vi.mocked(deploymentService.createDeployment).mockResolvedValue(
+ createMockDeployment() as any,
+ );
+ vi.mocked(execProcess.execAsync).mockResolvedValue({
+ stdout: "",
+ stderr: "",
+ } as any);
+ vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
+ undefined as any,
+ );
+ vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
+ undefined as any,
+ );
+ vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
+ {} as any,
+ );
+ vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
+ undefined as any,
+ );
+ vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
+ message: "test commit",
+ hash: "abc123",
+ });
+ vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
+ });
+
+ it("should generate correct git clone command for astro example", async () => {
+ const app = createMockApplication();
+ const command = await cloneGitRepository(app);
+ console.log(command);
+
+ expect(command).toContain("https://github.com/Dokploy/examples.git");
+ expect(command).not.toContain("--recurse-submodules");
+ expect(command).toContain("--branch main");
+ expect(command).toContain("--depth 1");
+ expect(command).toContain("git clone");
+ });
+
+ it("should generate git clone with submodules when enabled", async () => {
+ const app = createMockApplication({ enableSubmodules: true });
+ const command = await cloneGitRepository(app);
+
+ expect(command).toContain("--recurse-submodules");
+ expect(command).toContain("https://github.com/Dokploy/examples.git");
+ });
+
+ it("should verify nixpacks command is called with correct app", async () => {
+ const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
+ vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
+
+ await deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Test deployment",
+ descriptionLog: "",
+ });
+
+ expect(builders.getBuildCommand).toHaveBeenCalledWith(
+ expect.objectContaining({
+ buildType: "nixpacks",
+ customGitUrl: "https://github.com/Dokploy/examples.git",
+ buildPath: "/astro",
+ }),
+ );
+
+ expect(execProcess.execAsync).toHaveBeenCalledWith(
+ expect.stringContaining("nixpacks build"),
+ );
+ });
+
+ it("should verify railpack command includes correct parameters", async () => {
+ const mockApp = createMockApplication({ buildType: "railpack" });
+ vi.mocked(db.query.applications.findFirst).mockResolvedValue(
+ mockApp as any,
+ );
+ vi.mocked(applicationService.findApplicationById).mockResolvedValue(
+ mockApp as any,
+ );
+
+ const mockRailpackCommand = "railpack prepare /path/to/app";
+ vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
+
+ await deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Railpack test",
+ descriptionLog: "",
+ });
+
+ expect(builders.getBuildCommand).toHaveBeenCalledWith(
+ expect.objectContaining({
+ buildType: "railpack",
+ }),
+ );
+
+ expect(execProcess.execAsync).toHaveBeenCalledWith(
+ expect.stringContaining("railpack prepare"),
+ );
+ });
+
+ it("should execute commands in correct order", async () => {
+ const mockNixpacksCommand = "nixpacks build";
+ vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
+
+ await deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Test",
+ descriptionLog: "",
+ });
+
+ const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
+ expect(execCalls.length).toBeGreaterThan(0);
+
+ const fullCommand = execCalls[0]?.[0];
+ expect(fullCommand).toContain("set -e");
+ expect(fullCommand).toContain("git clone");
+ expect(fullCommand).toContain("nixpacks build");
+ });
+
+ it("should include log redirection in command", async () => {
+ const mockCommand = "nixpacks build";
+ vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
+
+ await deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Test",
+ descriptionLog: "",
+ });
+
+ const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
+ const fullCommand = execCalls[0]?.[0];
+
+ expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
+ });
+});
diff --git a/apps/dokploy/__test__/deploy/application.real.test.ts b/apps/dokploy/__test__/deploy/application.real.test.ts
new file mode 100644
index 000000000..43ff07836
--- /dev/null
+++ b/apps/dokploy/__test__/deploy/application.real.test.ts
@@ -0,0 +1,479 @@
+import { existsSync } from "node:fs";
+import path from "node:path";
+import type { ApplicationNested } from "@dokploy/server";
+import { paths } from "@dokploy/server/constants";
+import { execAsync } from "@dokploy/server/utils/process/execAsync";
+import { format } from "date-fns";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const REAL_TEST_TIMEOUT = 180000; // 3 minutes
+
+// Mock ONLY database and notifications
+vi.mock("@dokploy/server/db", () => {
+ const createChainableMock = (): any => {
+ const chain: any = {
+ set: vi.fn(() => chain),
+ where: vi.fn(() => chain),
+ returning: vi.fn().mockResolvedValue([{}]),
+ };
+ return chain;
+ };
+
+ return {
+ db: {
+ select: vi.fn(),
+ insert: vi.fn(),
+ update: vi.fn(() => createChainableMock()),
+ delete: vi.fn(),
+ query: {
+ applications: {
+ findFirst: vi.fn(),
+ },
+ },
+ },
+ };
+});
+
+vi.mock("@dokploy/server/services/application", async () => {
+ const actual = await vi.importActual<
+ typeof import("@dokploy/server/services/application")
+ >("@dokploy/server/services/application");
+ return {
+ ...actual,
+ findApplicationById: vi.fn(),
+ updateApplicationStatus: vi.fn(),
+ };
+});
+
+vi.mock("@dokploy/server/services/admin", () => ({
+ getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
+}));
+
+vi.mock("@dokploy/server/services/deployment", () => ({
+ createDeployment: vi.fn(),
+ updateDeploymentStatus: vi.fn(),
+ updateDeployment: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
+ sendBuildSuccessNotifications: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
+ sendBuildErrorNotifications: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/services/rollbacks", () => ({
+ createRollback: vi.fn(),
+}));
+
+// NOT mocked (executed for real):
+// - execAsync
+// - cloneGitRepository
+// - getBuildCommand
+// - mechanizeDockerContainer (requires Docker Swarm)
+
+import { db } from "@dokploy/server/db";
+import * as adminService from "@dokploy/server/services/admin";
+import * as applicationService from "@dokploy/server/services/application";
+import { deployApplication } from "@dokploy/server/services/application";
+import * as deploymentService from "@dokploy/server/services/deployment";
+
+const createMockApplication = (
+ overrides: Partial = {},
+): ApplicationNested =>
+ ({
+ applicationId: "test-app-id",
+ name: "Real Test App",
+ appName: `real-test-${Date.now()}`,
+ sourceType: "git" as const,
+ customGitUrl: "https://github.com/Dokploy/examples.git",
+ customGitBranch: "main",
+ customGitSSHKeyId: null,
+ customGitBuildPath: "/astro",
+ buildType: "nixpacks" as const,
+ env: "NODE_ENV=production",
+ serverId: null,
+ rollbackActive: false,
+ enableSubmodules: false,
+ environmentId: "env-id",
+ environment: {
+ projectId: "project-id",
+ env: "",
+ name: "production",
+ project: {
+ name: "Test Project",
+ organizationId: "org-id",
+ env: "",
+ },
+ },
+ domains: [],
+ mounts: [],
+ security: [],
+ redirects: [],
+ ports: [],
+ registry: null,
+ ...overrides,
+ }) as ApplicationNested;
+
+const createMockDeployment = async (appName: string) => {
+ const { LOGS_PATH } = paths(false); // false = local, no remote server
+ const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
+ const fileName = `${appName}-${formattedDateTime}.log`;
+ const logFilePath = path.join(LOGS_PATH, appName, fileName);
+
+ // Actually create the log directory
+ await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
+ await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
+
+ return {
+ deploymentId: "deployment-id",
+ logPath: logFilePath,
+ };
+};
+
+async function cleanupDocker(appName: string) {
+ try {
+ await execAsync(`docker stop ${appName} 2>/dev/null || true`);
+ await execAsync(`docker rm ${appName} 2>/dev/null || true`);
+ await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
+ } catch (error) {
+ console.log("Docker cleanup completed");
+ }
+}
+
+async function cleanupFiles(appName: string) {
+ try {
+ const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
+
+ // Clean cloned code directories
+ const appPath = path.join(APPLICATIONS_PATH, appName);
+ await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
+
+ // Clean logs for appName - removes entire folder
+ const logPath = path.join(LOGS_PATH, appName);
+ await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
+
+ console.log(`✅ Cleaned up files and logs for ${appName}`);
+ } catch (error) {
+ console.error(`⚠️ Error during cleanup for ${appName}:`, error);
+ }
+}
+
+describe(
+ "deployApplication - REAL Execution Tests",
+ () => {
+ let currentAppName: string;
+ let currentDeployment: any;
+ const allTestAppNames: string[] = [];
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ currentAppName = `real-test-${Date.now()}`;
+ currentDeployment = await createMockDeployment(currentAppName);
+ allTestAppNames.push(currentAppName);
+
+ const mockApp = createMockApplication({ appName: currentAppName });
+
+ vi.mocked(db.query.applications.findFirst).mockResolvedValue(
+ mockApp as any,
+ );
+ vi.mocked(applicationService.findApplicationById).mockResolvedValue(
+ mockApp as any,
+ );
+ vi.mocked(adminService.getDokployUrl).mockResolvedValue(
+ "http://localhost:3000",
+ );
+ vi.mocked(deploymentService.createDeployment).mockResolvedValue(
+ currentDeployment as any,
+ );
+ vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
+ undefined as any,
+ );
+ vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
+ {} as any,
+ );
+ vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
+ {} as any,
+ );
+ });
+
+ afterEach(async () => {
+ // ALWAYS cleanup, even if test failed or passed
+ console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
+
+ // Clean current appName
+ try {
+ await cleanupDocker(currentAppName);
+ await cleanupFiles(currentAppName);
+ } catch (error) {
+ console.error("⚠️ Error cleaning current app:", error);
+ }
+
+ // Clean ALL test folders just in case
+ try {
+ const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
+ await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
+ await execAsync(
+ `rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
+ );
+ console.log("✅ Cleaned up all test artifacts");
+ } catch (error) {
+ console.error("⚠️ Error cleaning all artifacts:", error);
+ }
+
+ console.log("✅ Cleanup completed\n");
+ });
+
+ it(
+ "should REALLY clone git repo and build with nixpacks",
+ async () => {
+ console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
+
+ const result = await deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Real Nixpacks Test",
+ descriptionLog: "Testing real execution",
+ });
+
+ expect(result).toBe(true);
+
+ // Verify that Docker image was actually created
+ const { stdout: dockerImages } = await execAsync(
+ `docker images ${currentAppName} --format "{{.Repository}}"`,
+ );
+ console.log("dockerImages", dockerImages);
+ expect(dockerImages.trim()).toBe(currentAppName);
+ console.log(`✅ Docker image created: ${currentAppName}`);
+
+ // Verify log exists and has content
+ expect(existsSync(currentDeployment.logPath)).toBe(true);
+ const { stdout: logContent } = await execAsync(
+ `cat ${currentDeployment.logPath}`,
+ );
+ expect(logContent).toContain("Cloning");
+ expect(logContent).toContain("nixpacks");
+ console.log(`✅ Build log created with ${logContent.length} chars`);
+
+ // Verify update functions were called
+ expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
+ "deployment-id",
+ "done",
+ );
+ },
+ REAL_TEST_TIMEOUT,
+ );
+
+ it.skip(
+ "should REALLY build with railpack (SKIPPED: requires special permissions)",
+ async () => {
+ const railpackAppName = `real-railpack-${Date.now()}`;
+ const railpackApp = createMockApplication({
+ appName: railpackAppName,
+ buildType: "railpack",
+ railpackVersion: "3",
+ });
+ currentAppName = railpackAppName;
+ allTestAppNames.push(railpackAppName);
+
+ vi.mocked(db.query.applications.findFirst).mockResolvedValue(
+ railpackApp as any,
+ );
+ vi.mocked(applicationService.findApplicationById).mockResolvedValue(
+ railpackApp as any,
+ );
+
+ console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
+
+ const result = await deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Real Railpack Test",
+ descriptionLog: "",
+ });
+
+ expect(result).toBe(true);
+
+ const { stdout: dockerImages } = await execAsync(
+ `docker images ${currentAppName} --format "{{.Repository}}"`,
+ );
+ expect(dockerImages.trim()).toBe(currentAppName);
+ console.log(`✅ Railpack image created: ${currentAppName}`);
+
+ const { stdout: logContent } = await execAsync(
+ `cat ${currentDeployment.logPath}`,
+ );
+ expect(logContent).toContain("railpack");
+ console.log("✅ Railpack build completed");
+ },
+ REAL_TEST_TIMEOUT,
+ );
+
+ it(
+ "should handle REAL git clone errors",
+ async () => {
+ const errorAppName = `real-error-${Date.now()}`;
+ const errorApp = createMockApplication({
+ appName: errorAppName,
+ customGitUrl:
+ "https://github.com/invalid/nonexistent-repo-123456.git",
+ });
+ currentAppName = errorAppName;
+ allTestAppNames.push(errorAppName);
+
+ vi.mocked(db.query.applications.findFirst).mockResolvedValue(
+ errorApp as any,
+ );
+ vi.mocked(applicationService.findApplicationById).mockResolvedValue(
+ errorApp as any,
+ );
+
+ console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
+
+ await expect(
+ deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Real Error Test",
+ descriptionLog: "",
+ }),
+ ).rejects.toThrow();
+
+ // Verify error status was called
+ expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
+ "deployment-id",
+ "error",
+ );
+
+ // Verify log contains error
+ const { stdout: logContent } = await execAsync(
+ `cat ${currentDeployment.logPath}`,
+ );
+ expect(logContent.toLowerCase()).toContain("error");
+ console.log("✅ Error handling verified");
+ },
+ REAL_TEST_TIMEOUT,
+ );
+
+ it(
+ "should REALLY clone with submodules when enabled",
+ async () => {
+ const submodulesAppName = `real-submodules-${Date.now()}`;
+ const submodulesApp = createMockApplication({
+ appName: submodulesAppName,
+ enableSubmodules: true,
+ });
+ currentAppName = submodulesAppName;
+ allTestAppNames.push(submodulesAppName);
+
+ vi.mocked(db.query.applications.findFirst).mockResolvedValue(
+ submodulesApp as any,
+ );
+ vi.mocked(applicationService.findApplicationById).mockResolvedValue(
+ submodulesApp as any,
+ );
+
+ console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
+
+ const result = await deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Real Submodules Test",
+ descriptionLog: "",
+ });
+
+ expect(result).toBe(true);
+
+ // Verify deployment completed successfully
+ const { stdout: logContent } = await execAsync(
+ `cat ${currentDeployment.logPath}`,
+ );
+ expect(logContent).toContain("Cloning");
+ expect(logContent.length).toBeGreaterThan(100);
+ console.log("✅ Submodules deployment completed");
+
+ // Verify image
+ const { stdout: dockerImages } = await execAsync(
+ `docker images ${currentAppName} --format "{{.Repository}}"`,
+ );
+ expect(dockerImages.trim()).toBe(currentAppName);
+ },
+ REAL_TEST_TIMEOUT,
+ );
+
+ it(
+ "should verify REAL commit info extraction",
+ async () => {
+ console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
+
+ await deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Real Commit Test",
+ descriptionLog: "",
+ });
+
+ // Verify updateDeployment was called with commit info
+ expect(deploymentService.updateDeployment).toHaveBeenCalled();
+ const updateCall = vi.mocked(deploymentService.updateDeployment).mock
+ .calls[0];
+
+ // Real commit info should have title and hash
+ expect(updateCall?.[1]).toHaveProperty("title");
+ expect(updateCall?.[1]).toHaveProperty("description");
+ expect(updateCall?.[1]?.description).toContain("Commit:");
+
+ console.log(
+ `✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
+ );
+ },
+ REAL_TEST_TIMEOUT,
+ );
+
+ it(
+ "should REALLY build with Dockerfile",
+ async () => {
+ const dockerfileAppName = `real-dockerfile-${Date.now()}`;
+ const dockerfileApp = createMockApplication({
+ appName: dockerfileAppName,
+ buildType: "dockerfile",
+ customGitBuildPath: "/deno",
+ dockerfile: "Dockerfile",
+ });
+ currentAppName = dockerfileAppName;
+ allTestAppNames.push(dockerfileAppName);
+
+ vi.mocked(db.query.applications.findFirst).mockResolvedValue(
+ dockerfileApp as any,
+ );
+ vi.mocked(applicationService.findApplicationById).mockResolvedValue(
+ dockerfileApp as any,
+ );
+
+ console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
+
+ const result = await deployApplication({
+ applicationId: "test-app-id",
+ titleLog: "Real Dockerfile Test",
+ descriptionLog: "",
+ });
+
+ expect(result).toBe(true);
+
+ // Verify log
+ const { stdout: logContent } = await execAsync(
+ `cat ${currentDeployment.logPath}`,
+ );
+ expect(logContent).toContain("Building");
+ expect(logContent).toContain(dockerfileAppName);
+ console.log("✅ Dockerfile build log verified");
+
+ // Verify image
+ const { stdout: dockerImages } = await execAsync(
+ `docker images ${currentAppName} --format "{{.Repository}}"`,
+ );
+ console.log("dockerImages", dockerImages);
+ expect(dockerImages.trim()).toBe(currentAppName);
+ console.log(`✅ Docker image created: ${currentAppName}`);
+ },
+ REAL_TEST_TIMEOUT,
+ );
+ },
+ REAL_TEST_TIMEOUT,
+);
diff --git a/apps/dokploy/__test__/deploy/github.test.ts b/apps/dokploy/__test__/deploy/github.test.ts
index 03805b08d..46be44883 100644
--- a/apps/dokploy/__test__/deploy/github.test.ts
+++ b/apps/dokploy/__test__/deploy/github.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
-import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
+import {
+ extractCommitMessage,
+ extractImageName,
+ extractImageTag,
+ extractImageTagFromRequest,
+} from "@/pages/api/deploy/[refreshToken]";
describe("GitHub Webhook Skip CI", () => {
const mockGithubHeaders = {
@@ -96,3 +101,308 @@ describe("GitHub Webhook Skip CI", () => {
);
});
});
+
+describe("GitHub Packages Docker Image Tag Extraction", () => {
+ it("should extract tag from container_metadata", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {
+ package_version: {
+ version: "sha256:abc123...",
+ container_metadata: {
+ tag: {
+ name: "v1.0.0",
+ digest: "sha256:abc123...",
+ },
+ },
+ package_url: "ghcr.io/owner/repo:v1.0.0",
+ },
+ },
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBe("v1.0.0");
+ });
+
+ it("should extract tag from package_url when container_metadata tag matches version", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {
+ package_version: {
+ version: "sha256:abc123...",
+ container_metadata: {
+ tag: {
+ name: "sha256:abc123...",
+ digest: "sha256:abc123...",
+ },
+ },
+ package_url: "ghcr.io/owner/repo:latest",
+ },
+ },
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBe("latest");
+ });
+
+ it("should extract tag from package_url when container_metadata is missing", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {
+ package_version: {
+ version: "sha256:abc123...",
+ package_url: "ghcr.io/owner/repo:1.2.3",
+ },
+ },
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBe("1.2.3");
+ });
+
+ it("should handle different tag formats in package_url", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const testCases = [
+ { url: "ghcr.io/owner/repo:latest", expected: "latest" },
+ { url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
+ { url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
+ { url: "ghcr.io/owner/repo:dev", expected: "dev" },
+ ];
+
+ for (const testCase of testCases) {
+ const body = {
+ registry_package: {
+ package_version: {
+ version: "sha256:abc123...",
+ package_url: testCase.url,
+ },
+ },
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBe(testCase.expected);
+ }
+ });
+
+ it("should return null for non-registry_package events", () => {
+ const headers = { "x-github-event": "push" };
+ const body = {
+ registry_package: {
+ package_version: {
+ package_url: "ghcr.io/owner/repo:latest",
+ },
+ },
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBeNull();
+ });
+
+ it("should return null when package_version is missing", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {},
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBeNull();
+ });
+
+ it("should return null when package_url has no tag", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {
+ package_version: {
+ version: "sha256:abc123...",
+ package_url: "ghcr.io/owner/repo",
+ },
+ },
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBeNull();
+ });
+
+ it("should return null when package_url ends with colon (no tag)", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {
+ package_version: {
+ version: "sha256:abc123...",
+ package_url: "ghcr.io/owner/repo:",
+ container_metadata: {
+ tag: {
+ name: "",
+ digest: "sha256:abc123...",
+ },
+ },
+ },
+ },
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBeNull();
+ });
+
+ it("should return null when tag name is empty string", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {
+ package_version: {
+ version: "sha256:abc123...",
+ container_metadata: {
+ tag: {
+ name: "",
+ digest: "sha256:abc123...",
+ },
+ },
+ package_url: "ghcr.io/owner/repo:",
+ },
+ },
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBeNull();
+ });
+
+ it("should ignore tag if it matches the version (digest)", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {
+ package_version: {
+ version: "sha256:abc123...",
+ container_metadata: {
+ tag: {
+ name: "sha256:abc123...",
+ digest: "sha256:abc123...",
+ },
+ },
+ package_url: "ghcr.io/owner/repo:latest",
+ },
+ },
+ };
+
+ const tag = extractImageTagFromRequest(headers, body);
+ expect(tag).toBe("latest");
+ });
+
+ it("should handle registry_package commit message with package_url", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {
+ package_version: {
+ package_url: "ghcr.io/owner/repo:latest",
+ },
+ },
+ };
+
+ const message = extractCommitMessage(headers, body);
+ expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
+ });
+
+ it("should handle registry_package commit message when package_url is missing", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {
+ package_version: {
+ version: "sha256:abc123...",
+ },
+ },
+ };
+
+ const message = extractCommitMessage(headers, body);
+ expect(message).toBe("Docker GHCR image pushed");
+ });
+
+ it("should handle registry_package commit message when package_version is missing", () => {
+ const headers = { "x-github-event": "registry_package" };
+ const body = {
+ registry_package: {},
+ };
+
+ const message = extractCommitMessage(headers, body);
+ expect(message).toBe("NEW COMMIT");
+ });
+});
+
+describe("Docker Image Name and Tag Extraction", () => {
+ describe("extractImageName", () => {
+ it("should return image name without tag", () => {
+ expect(extractImageName("my-image:latest")).toBe("my-image");
+ expect(extractImageName("my-image:1.0.0")).toBe("my-image");
+ expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
+ "ghcr.io/owner/repo",
+ );
+ });
+
+ it("should return full image name when no tag is present", () => {
+ expect(extractImageName("my-image")).toBe("my-image");
+ expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
+ });
+
+ it("should handle images with port numbers correctly", () => {
+ expect(extractImageName("registry:5000/image:tag")).toBe(
+ "registry:5000/image",
+ );
+ expect(extractImageName("localhost:5000/my-app:latest")).toBe(
+ "localhost:5000/my-app",
+ );
+ });
+
+ it("should handle complex image paths", () => {
+ expect(
+ extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
+ ).toBe("myregistryhost:5000/fedora/httpd");
+ expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
+ "registry.example.com:8080/ns/app",
+ );
+ });
+
+ it("should return null for invalid inputs", () => {
+ expect(extractImageName(null)).toBeNull();
+ expect(extractImageName("")).toBeNull();
+ });
+
+ it("should handle edge cases with multiple colons", () => {
+ expect(extractImageName("image:tag:extra")).toBe("image:tag");
+ expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
+ });
+ });
+
+ describe("extractImageTag", () => {
+ it("should extract tag from image with tag", () => {
+ expect(extractImageTag("my-image:latest")).toBe("latest");
+ expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
+ expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
+ });
+
+ it("should return 'latest' when no tag is present", () => {
+ expect(extractImageTag("my-image")).toBe("latest");
+ expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
+ });
+
+ it("should handle complex image paths with tags", () => {
+ expect(
+ extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
+ ).toBe("version1.0");
+ expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
+ "v1.2.3",
+ );
+ });
+
+ it("should return null for invalid inputs", () => {
+ expect(extractImageTag(null)).toBeNull();
+ expect(extractImageTag("")).toBeNull();
+ });
+
+ it("should handle edge cases with multiple colons", () => {
+ expect(extractImageTag("image:tag:extra")).toBe("extra");
+ expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
+ });
+
+ it("should handle numeric tags", () => {
+ expect(extractImageTag("my-image:123")).toBe("123");
+ expect(extractImageTag("my-image:1")).toBe("1");
+ });
+ });
+});
diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts
index cbfebbfac..85b9b2c61 100644
--- a/apps/dokploy/__test__/drop/drop.test.ts
+++ b/apps/dokploy/__test__/drop/drop.test.ts
@@ -25,11 +25,17 @@ if (typeof window === "undefined") {
}
const baseApp: ApplicationNested = {
- railpackVersion: "0.2.2",
+ railpackVersion: "0.15.4",
applicationId: "",
previewLabels: [],
+ createEnvFile: true,
+ bitbucketRepositorySlug: "",
herokuVersion: "",
giteaBranch: "",
+ buildServerId: "",
+ buildRegistryId: "",
+ buildRegistry: null,
+ args: [],
giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "",
@@ -37,11 +43,15 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
cleanCache: false,
watchPaths: [],
+ rollbackRegistryId: "",
+ rollbackRegistry: null,
+ deployments: [],
enableSubmodules: false,
applicationStatus: "done",
triggerType: "push",
appName: "",
autoDeploy: true,
+ endpointSpecSwarm: null,
serverId: "",
registryUrl: "",
branch: null,
@@ -59,6 +69,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
environment: {
env: "",
+ isDefault: false,
environmentId: "",
name: "",
createdAt: "",
diff --git a/apps/dokploy/__test__/env/environment-access-fallback.test.ts b/apps/dokploy/__test__/env/environment-access-fallback.test.ts
new file mode 100644
index 000000000..a4b56393a
--- /dev/null
+++ b/apps/dokploy/__test__/env/environment-access-fallback.test.ts
@@ -0,0 +1,294 @@
+import { describe, expect, it } from "vitest";
+
+// Type definitions matching the project structure
+type Environment = {
+ environmentId: string;
+ name: string;
+ isDefault: boolean;
+};
+
+type Project = {
+ projectId: string;
+ name: string;
+ environments: Environment[];
+};
+
+/**
+ * Helper function that selects the appropriate environment for a user
+ * This matches the logic used in search-command.tsx and show.tsx
+ */
+function selectAccessibleEnvironment(
+ project: Project | null | undefined,
+): Environment | null {
+ if (!project || !project.environments || project.environments.length === 0) {
+ return null;
+ }
+
+ // Find default environment from accessible environments, or fall back to first accessible environment
+ const defaultEnvironment =
+ project.environments.find((environment) => environment.isDefault) ||
+ project.environments[0];
+
+ return defaultEnvironment || null;
+}
+
+describe("Environment Access Fallback", () => {
+ describe("selectAccessibleEnvironment", () => {
+ it("should return default environment when user has access to it", () => {
+ const project: Project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: [
+ {
+ environmentId: "env-prod",
+ name: "production",
+ isDefault: true,
+ },
+ {
+ environmentId: "env-dev",
+ name: "development",
+ isDefault: false,
+ },
+ ],
+ };
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).not.toBeNull();
+ expect(result?.environmentId).toBe("env-prod");
+ expect(result?.isDefault).toBe(true);
+ });
+
+ it("should return first accessible environment when user doesn't have access to default", () => {
+ // Simulating filtered environments (user only has access to development)
+ const project: Project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: [
+ // Note: production is not in the list because user doesn't have access
+ {
+ environmentId: "env-dev",
+ name: "development",
+ isDefault: false,
+ },
+ {
+ environmentId: "env-staging",
+ name: "staging",
+ isDefault: false,
+ },
+ ],
+ };
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).not.toBeNull();
+ expect(result?.environmentId).toBe("env-dev");
+ expect(result?.name).toBe("development");
+ });
+
+ it("should return first environment when no default is marked but environments exist", () => {
+ const project: Project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: [
+ {
+ environmentId: "env-dev",
+ name: "development",
+ isDefault: false,
+ },
+ {
+ environmentId: "env-staging",
+ name: "staging",
+ isDefault: false,
+ },
+ ],
+ };
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).not.toBeNull();
+ expect(result?.environmentId).toBe("env-dev");
+ });
+
+ it("should return null when project has no accessible environments", () => {
+ const project: Project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: [],
+ };
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).toBeNull();
+ });
+
+ it("should return null when project is null", () => {
+ const result = selectAccessibleEnvironment(null);
+
+ expect(result).toBeNull();
+ });
+
+ it("should return null when project is undefined", () => {
+ const result = selectAccessibleEnvironment(undefined);
+
+ expect(result).toBeNull();
+ });
+
+ it("should handle project with single accessible environment", () => {
+ const project: Project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: [
+ {
+ environmentId: "env-dev",
+ name: "development",
+ isDefault: false,
+ },
+ ],
+ };
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).not.toBeNull();
+ expect(result?.environmentId).toBe("env-dev");
+ });
+
+ it("should prioritize default environment even when it's not first in the array", () => {
+ const project: Project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: [
+ {
+ environmentId: "env-dev",
+ name: "development",
+ isDefault: false,
+ },
+ {
+ environmentId: "env-staging",
+ name: "staging",
+ isDefault: false,
+ },
+ {
+ environmentId: "env-prod",
+ name: "production",
+ isDefault: true,
+ },
+ ],
+ };
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).not.toBeNull();
+ expect(result?.environmentId).toBe("env-prod");
+ expect(result?.isDefault).toBe(true);
+ });
+
+ it("should handle multiple default environments by returning the first one found", () => {
+ // Edge case: multiple environments marked as default (shouldn't happen, but test it)
+ const project: Project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: [
+ {
+ environmentId: "env-prod-1",
+ name: "production-1",
+ isDefault: true,
+ },
+ {
+ environmentId: "env-prod-2",
+ name: "production-2",
+ isDefault: true,
+ },
+ ],
+ };
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).not.toBeNull();
+ expect(result?.isDefault).toBe(true);
+ // Should return the first default found
+ expect(result?.environmentId).toBe("env-prod-1");
+ });
+
+ it("should work correctly when user has access to multiple environments including default", () => {
+ const project: Project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: [
+ {
+ environmentId: "env-prod",
+ name: "production",
+ isDefault: true,
+ },
+ {
+ environmentId: "env-dev",
+ name: "development",
+ isDefault: false,
+ },
+ {
+ environmentId: "env-staging",
+ name: "staging",
+ isDefault: false,
+ },
+ ],
+ };
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).not.toBeNull();
+ expect(result?.environmentId).toBe("env-prod");
+ expect(result?.isDefault).toBe(true);
+ });
+
+ it("should handle real-world scenario: user with only development access", () => {
+ // This simulates the exact bug we're fixing:
+ // User has access to development but not production (default)
+ // The filtered environments array only contains development
+ const project: Project = {
+ projectId: "proj-1",
+ name: "My Project",
+ environments: [
+ // Only development is accessible (production was filtered out)
+ {
+ environmentId: "env-dev-123",
+ name: "development",
+ isDefault: false,
+ },
+ ],
+ };
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).not.toBeNull();
+ expect(result?.environmentId).toBe("env-dev-123");
+ expect(result?.name).toBe("development");
+ // Should not be null even though it's not the default
+ });
+ });
+
+ describe("Environment selection edge cases", () => {
+ it("should handle project with environments property as undefined", () => {
+ const project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: undefined,
+ } as unknown as Project;
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).toBeNull();
+ });
+
+ it("should handle project with null environments array", () => {
+ const project = {
+ projectId: "proj-1",
+ name: "Test Project",
+ environments: null,
+ } as unknown as Project;
+
+ const result = selectAccessibleEnvironment(project);
+
+ expect(result).toBeNull();
+ });
+ });
+});
diff --git a/apps/dokploy/__test__/env/environment.test.ts b/apps/dokploy/__test__/env/environment.test.ts
index 95d46dcc0..24ef18b00 100644
--- a/apps/dokploy/__test__/env/environment.test.ts
+++ b/apps/dokploy/__test__/env/environment.test.ts
@@ -1,4 +1,7 @@
-import { prepareEnvironmentVariables } from "@dokploy/server/index";
+import {
+ prepareEnvironmentVariables,
+ prepareEnvironmentVariablesForShell,
+} from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
@@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
"IS_DEV=0",
]);
});
+
+ it("handles environment variables with single quotes in values", () => {
+ const envWithSingleQuotes = `
+ENV_VARIABLE='ENVITONME'NT'
+ANOTHER_VAR='value with 'quotes' inside'
+SIMPLE_VAR=no-quotes
+`;
+
+ const serviceWithSingleQuotes = `
+TEST_VAR=\${{environment.ENV_VARIABLE}}
+ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
+SIMPLE=\${{environment.SIMPLE_VAR}}
+`;
+
+ const resolved = prepareEnvironmentVariables(
+ serviceWithSingleQuotes,
+ "",
+ envWithSingleQuotes,
+ );
+
+ expect(resolved).toEqual([
+ "TEST_VAR=ENVITONME'NT",
+ "ANOTHER_TEST=value with 'quotes' inside",
+ "SIMPLE=no-quotes",
+ ]);
+ });
+});
+
+describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
+ it("escapes single quotes in environment variable values", () => {
+ const serviceEnv = `
+ENV_VARIABLE='ENVITONME'NT'
+ANOTHER_VAR='value with 'quotes' inside'
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ // shell-quote should wrap these in double quotes
+ expect(resolved).toEqual([
+ `"ENV_VARIABLE=ENVITONME'NT"`,
+ `"ANOTHER_VAR=value with 'quotes' inside"`,
+ ]);
+ });
+
+ it("escapes double quotes in environment variable values", () => {
+ const serviceEnv = `
+MESSAGE="Hello "World""
+QUOTED_PATH="/path/to/"file""
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ // shell-quote wraps in single quotes when there are double quotes inside
+ expect(resolved).toEqual([
+ `'MESSAGE=Hello "World"'`,
+ `'QUOTED_PATH=/path/to/"file"'`,
+ ]);
+ });
+
+ it("escapes dollar signs in environment variable values", () => {
+ const serviceEnv = `
+PRICE=$100
+VARIABLE=$HOME/path
+TEMPLATE=Hello $USER
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ // Dollar signs should be escaped to prevent variable expansion
+ for (const env of resolved) {
+ expect(env).toContain("$");
+ }
+ });
+
+ it("escapes backticks in environment variable values", () => {
+ const serviceEnv = `
+COMMAND=\`echo "test"\`
+NESTED=value with \`backticks\` inside
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ // Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
+ expect(resolved.length).toBe(2);
+ expect(resolved[0]).toContain("COMMAND");
+ expect(resolved[1]).toContain("NESTED");
+ });
+
+ it("handles environment variables with spaces", () => {
+ const serviceEnv = `
+FULL_NAME="John Doe"
+MESSAGE='Hello World'
+SENTENCE=This is a test
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ // shell-quote uses single quotes for strings with spaces
+ expect(resolved).toEqual([
+ `'FULL_NAME=John Doe'`,
+ `'MESSAGE=Hello World'`,
+ `'SENTENCE=This is a test'`,
+ ]);
+ });
+
+ it("handles environment variables with backslashes", () => {
+ const serviceEnv = `
+WINDOWS_PATH=C:\\Users\\Documents
+ESCAPED=value\\with\\backslashes
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ // Backslashes should be properly escaped
+ expect(resolved.length).toBe(2);
+ for (const env of resolved) {
+ expect(env).toContain("\\");
+ }
+ });
+
+ it("handles simple environment variables without special characters", () => {
+ const serviceEnv = `
+NODE_ENV=production
+PORT=3000
+DEBUG=true
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ // shell-quote escapes the = sign in some cases
+ expect(resolved).toEqual([
+ "NODE_ENV\\=production",
+ "PORT\\=3000",
+ "DEBUG\\=true",
+ ]);
+ });
+
+ it("handles environment variables with mixed special characters", () => {
+ const serviceEnv = `
+COMPLEX='value with "double" and 'single' quotes'
+BASH_COMMAND=echo "$HOME" && echo 'test'
+WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ // All should be escaped, none should throw errors
+ expect(resolved.length).toBe(3);
+ // Verify each can be safely used in shell
+ for (const env of resolved) {
+ expect(typeof env).toBe("string");
+ expect(env.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("handles environment variables with newlines", () => {
+ const serviceEnv = `
+MULTILINE="line1
+line2
+line3"
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ expect(resolved.length).toBe(1);
+ expect(resolved[0]).toContain("MULTILINE");
+ });
+
+ it("handles empty environment variable values", () => {
+ const serviceEnv = `
+EMPTY=
+EMPTY_QUOTED=""
+EMPTY_SINGLE=''
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ // shell-quote escapes the = sign for empty values
+ expect(resolved).toEqual([
+ "EMPTY\\=",
+ "EMPTY_QUOTED\\=",
+ "EMPTY_SINGLE\\=",
+ ]);
+ });
+
+ it("handles environment variables with equals signs in values", () => {
+ const serviceEnv = `
+EQUATION=a=b+c
+CONNECTION_STRING=user=admin;password=test
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ expect(resolved.length).toBe(2);
+ expect(resolved[0]).toContain("EQUATION");
+ expect(resolved[1]).toContain("CONNECTION_STRING");
+ });
+
+ it("resolves and escapes environment variables together", () => {
+ const projectEnv = `
+BASE_URL=https://example.com
+API_KEY='secret-key-with-quotes'
+`;
+
+ const environmentEnv = `
+ENV_NAME=production
+DB_PASS='pa$$word'
+`;
+
+ const serviceEnv = `
+FULL_URL=\${{project.BASE_URL}}/api
+AUTH_KEY=\${{project.API_KEY}}
+ENVIRONMENT=\${{environment.ENV_NAME}}
+DB_PASSWORD=\${{environment.DB_PASS}}
+CUSTOM='value with 'quotes' inside'
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(
+ serviceEnv,
+ projectEnv,
+ environmentEnv,
+ );
+
+ expect(resolved.length).toBe(5);
+ // All resolved values should be properly escaped
+ for (const env of resolved) {
+ expect(typeof env).toBe("string");
+ }
+ });
+
+ it("handles environment variables with semicolons and ampersands", () => {
+ const serviceEnv = `
+COMMAND=echo "test" && echo "test2"
+MULTIPLE=cmd1; cmd2; cmd3
+URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ expect(resolved.length).toBe(3);
+ // These should be safely escaped to prevent command injection
+ for (const env of resolved) {
+ expect(typeof env).toBe("string");
+ expect(env.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("handles environment variables with pipes and redirects", () => {
+ const serviceEnv = `
+PIPE_COMMAND=cat file | grep test
+REDIRECT=echo "test" > output.txt
+BOTH=cat input.txt | grep pattern > output.txt
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ expect(resolved.length).toBe(3);
+ // Pipes and redirects should be safely quoted
+ expect(resolved[0]).toContain("PIPE_COMMAND");
+ expect(resolved[1]).toContain("REDIRECT");
+ expect(resolved[2]).toContain("BOTH");
+ // At least one should contain a pipe
+ const hasPipe = resolved.some((env) => env.includes("|"));
+ expect(hasPipe).toBe(true);
+ });
+
+ it("handles environment variables with parentheses and brackets", () => {
+ const serviceEnv = `
+MATH=(a+b)*c
+ARRAY=[1,2,3]
+JSON={"key":"value"}
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ expect(resolved.length).toBe(3);
+ expect(resolved[0]).toContain("(");
+ expect(resolved[1]).toContain("[");
+ expect(resolved[2]).toContain("{");
+ });
+
+ it("handles very long environment variable values", () => {
+ const longValue = "a".repeat(10000);
+ const serviceEnv = `LONG_VAR=${longValue}`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ expect(resolved.length).toBe(1);
+ expect(resolved[0]).toContain("LONG_VAR");
+ expect(resolved[0]?.length).toBeGreaterThan(10000);
+ });
+
+ it("handles special unicode characters in environment variables", () => {
+ const serviceEnv = `
+EMOJI=Hello 🌍 World 🚀
+CHINESE=你好世界
+SPECIAL=café résumé naïve
+`;
+
+ const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
+
+ expect(resolved.length).toBe(3);
+ expect(resolved[0]).toContain("🌍");
+ expect(resolved[1]).toContain("你好");
+ expect(resolved[2]).toContain("café");
+ });
});
diff --git a/apps/dokploy/__test__/env/stack-environment.test.ts b/apps/dokploy/__test__/env/stack-environment.test.ts
new file mode 100644
index 000000000..13f5adb53
--- /dev/null
+++ b/apps/dokploy/__test__/env/stack-environment.test.ts
@@ -0,0 +1,184 @@
+import { getEnviromentVariablesObject } from "@dokploy/server/index";
+import { describe, expect, it } from "vitest";
+
+const projectEnv = `
+ENVIRONMENT=staging
+DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
+PORT=3000
+`;
+
+const environmentEnv = `
+NODE_ENV=development
+API_URL=https://api.dev.example.com
+REDIS_URL=redis://localhost:6379
+DATABASE_NAME=dev_database
+SECRET_KEY=env-secret-123
+`;
+
+describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
+ it("resolves environment variables correctly for Stack compose", () => {
+ const serviceEnv = `
+FOO=\${{environment.NODE_ENV}}
+BAR=\${{environment.API_URL}}
+BAZ=test
+`;
+
+ const result = getEnviromentVariablesObject(
+ serviceEnv,
+ projectEnv,
+ environmentEnv,
+ );
+
+ expect(result).toEqual({
+ FOO: "development",
+ BAR: "https://api.dev.example.com",
+ BAZ: "test",
+ });
+ });
+
+ it("resolves both project and environment variables for Stack compose", () => {
+ const serviceEnv = `
+ENVIRONMENT=\${{project.ENVIRONMENT}}
+NODE_ENV=\${{environment.NODE_ENV}}
+API_URL=\${{environment.API_URL}}
+DATABASE_URL=\${{project.DATABASE_URL}}
+SERVICE_PORT=4000
+`;
+
+ const result = getEnviromentVariablesObject(
+ serviceEnv,
+ projectEnv,
+ environmentEnv,
+ );
+
+ expect(result).toEqual({
+ ENVIRONMENT: "staging",
+ NODE_ENV: "development",
+ API_URL: "https://api.dev.example.com",
+ DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db",
+ SERVICE_PORT: "4000",
+ });
+ });
+
+ it("handles multiple environment references in single value for Stack compose", () => {
+ const multiRefEnv = `
+HOST=localhost
+PORT=5432
+USERNAME=postgres
+PASSWORD=secret123
+`;
+
+ const serviceEnv = `
+DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
+`;
+
+ const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
+
+ expect(result).toEqual({
+ DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
+ });
+ });
+
+ it("throws error for undefined environment variables in Stack compose", () => {
+ const serviceWithUndefined = `
+UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
+`;
+
+ expect(() =>
+ getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
+ ).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
+ });
+
+ it("allows service variables to override environment variables in Stack compose", () => {
+ const serviceOverrideEnv = `
+NODE_ENV=production
+API_URL=\${{environment.API_URL}}
+`;
+
+ const result = getEnviromentVariablesObject(
+ serviceOverrideEnv,
+ "",
+ environmentEnv,
+ );
+
+ expect(result).toEqual({
+ NODE_ENV: "production",
+ API_URL: "https://api.dev.example.com",
+ });
+ });
+
+ it("resolves complex references with project, environment, and service variables for Stack compose", () => {
+ const complexServiceEnv = `
+FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
+API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
+SERVICE_NAME=my-service
+COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
+`;
+
+ const result = getEnviromentVariablesObject(
+ complexServiceEnv,
+ projectEnv,
+ environmentEnv,
+ );
+
+ expect(result).toEqual({
+ FULL_DATABASE_URL:
+ "postgres://postgres:postgres@localhost:5432/project_db/dev_database",
+ API_ENDPOINT: "https://api.dev.example.com/staging/api",
+ SERVICE_NAME: "my-service",
+ COMPLEX_VAR: "my-service-development-staging",
+ });
+ });
+
+ it("maintains precedence: service > environment > project in Stack compose", () => {
+ const conflictingProjectEnv = `
+NODE_ENV=production-project
+API_URL=https://project.api.com
+DATABASE_NAME=project_db
+`;
+
+ const conflictingEnvironmentEnv = `
+NODE_ENV=development-environment
+API_URL=https://environment.api.com
+DATABASE_NAME=env_db
+`;
+
+ const serviceWithConflicts = `
+NODE_ENV=service-override
+PROJECT_ENV=\${{project.NODE_ENV}}
+ENV_VAR=\${{environment.API_URL}}
+DB_NAME=\${{environment.DATABASE_NAME}}
+`;
+
+ const result = getEnviromentVariablesObject(
+ serviceWithConflicts,
+ conflictingProjectEnv,
+ conflictingEnvironmentEnv,
+ );
+
+ expect(result).toEqual({
+ NODE_ENV: "service-override",
+ PROJECT_ENV: "production-project",
+ ENV_VAR: "https://environment.api.com",
+ DB_NAME: "env_db",
+ });
+ });
+
+ it("handles empty environment variables in Stack compose", () => {
+ const serviceWithEmpty = `
+SERVICE_VAR=test
+PROJECT_VAR=\${{project.ENVIRONMENT}}
+`;
+
+ const result = getEnviromentVariablesObject(
+ serviceWithEmpty,
+ projectEnv,
+ "",
+ );
+
+ expect(result).toEqual({
+ SERVICE_VAR: "test",
+ PROJECT_VAR: "staging",
+ });
+ });
+});
diff --git a/apps/dokploy/__test__/requests/request.test.ts b/apps/dokploy/__test__/requests/request.test.ts
index 53ca8d777..3f58ac439 100644
--- a/apps/dokploy/__test__/requests/request.test.ts
+++ b/apps/dokploy/__test__/requests/request.test.ts
@@ -54,4 +54,22 @@ describe("processLogs", () => {
const result = parseRawConfig(entryWithWhitespace);
expect(result.data).toHaveLength(2);
});
+
+ it("should filter out Dokploy dashboard requests", () => {
+ const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
+
+ // Test with only Dokploy dashboard entry - should be filtered out
+ const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
+ expect(resultOnlyDokploy.data).toHaveLength(0);
+ expect(resultOnlyDokploy.totalCount).toBe(0);
+
+ // Test with mixed entries - Dokploy should be filtered, others should remain
+ const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
+ const resultMixed = parseRawConfig(mixedEntries);
+ expect(resultMixed.data).toHaveLength(1);
+ expect(resultMixed.totalCount).toBe(1);
+ expect(resultMixed.data[0]?.ServiceName).not.toBe(
+ "dokploy-service-app@file",
+ );
+ });
});
diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts
index 6eb5d1831..c12a272bc 100644
--- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts
+++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts
@@ -1,10 +1,13 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
+import { beforeEach, describe, expect, it, vi } from "vitest";
type MockCreateServiceOptions = {
- StopGracePeriod?: number;
+ TaskTemplate?: {
+ ContainerSpec?: {
+ StopGracePeriod?: number;
+ };
+ };
[key: string]: unknown;
};
@@ -82,8 +85,10 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
- expect(settings.StopGracePeriod).toBe(0);
- expect(typeof settings.StopGracePeriod).toBe("number");
+ expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
+ expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
+ "number",
+ );
});
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
@@ -97,6 +102,8 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
- expect(settings).not.toHaveProperty("StopGracePeriod");
+ expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
+ "StopGracePeriod",
+ );
});
});
diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts
index 3ae92ae20..f2af2717b 100644
--- a/apps/dokploy/__test__/templates/helpers.template.test.ts
+++ b/apps/dokploy/__test__/templates/helpers.template.test.ts
@@ -161,6 +161,50 @@ describe("helpers functions", () => {
});
});
+ describe("Empty string variables", () => {
+ it("should replace variables with empty string values correctly", () => {
+ const variables = {
+ smtp_username: "",
+ smtp_password: "",
+ non_empty: "value",
+ };
+
+ const result1 = processValue("${smtp_username}", variables, mockSchema);
+ expect(result1).toBe("");
+
+ const result2 = processValue("${smtp_password}", variables, mockSchema);
+ expect(result2).toBe("");
+
+ const result3 = processValue("${non_empty}", variables, mockSchema);
+ expect(result3).toBe("value");
+ });
+
+ it("should not replace undefined variables", () => {
+ const variables = {
+ defined_var: "",
+ };
+
+ const result = processValue("${undefined_var}", variables, mockSchema);
+ expect(result).toBe("${undefined_var}");
+ });
+
+ it("should handle mixed empty and non-empty variables in template", () => {
+ const variables = {
+ smtp_address: "smtp.example.com",
+ smtp_port: "2525",
+ smtp_username: "",
+ smtp_password: "",
+ };
+
+ const template =
+ "SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
+ const result = processValue(template, variables, mockSchema);
+ expect(result).toBe(
+ "SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
+ );
+ });
+ });
+
describe("${jwt}", () => {
it("should generate a JWT string", () => {
const jwt = processValue("${jwt}", {}, mockSchema);
diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
index 6858f0f00..b422279ca 100644
--- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
+++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
@@ -5,19 +5,27 @@ vi.mock("node:fs", () => ({
default: fs,
}));
-import type { FileConfig, User } from "@dokploy/server";
+import type { FileConfig } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
updateServerTraefik,
} from "@dokploy/server";
+import type { webServerSettings } from "@dokploy/server/db/schema";
import { beforeEach, expect, test, vi } from "vitest";
-const baseAdmin: User = {
+type WebServerSettings = typeof webServerSettings.$inferSelect;
+
+const baseSettings: WebServerSettings = {
+ id: "",
https: false,
- enablePaidFeatures: false,
- allowImpersonation: false,
- role: "user",
+ certificateType: "none",
+ host: null,
+ serverIp: null,
+ letsEncryptEmail: null,
+ sshPrivateKey: null,
+ enableDockerCleanup: false,
+ logCleanupCron: null,
metricsConfig: {
containers: {
refreshRate: 20,
@@ -43,30 +51,8 @@ const baseAdmin: User = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
- createdAt: new Date(),
- serverIp: null,
- certificateType: "none",
- host: null,
- letsEncryptEmail: null,
- sshPrivateKey: null,
- enableDockerCleanup: false,
- logCleanupCron: null,
- serversQuantity: 0,
- stripeCustomerId: "",
- stripeSubscriptionId: "",
- banExpires: new Date(),
- banned: true,
- banReason: "",
- email: "",
- expirationDate: "",
- id: "",
- isRegistered: false,
- name: "",
- createdAt2: new Date().toISOString(),
- emailVerified: false,
- image: "",
+ createdAt: null,
updatedAt: new Date(),
- twoFactorEnabled: false,
};
beforeEach(() => {
@@ -84,7 +70,7 @@ test("Should read the configuration file", () => {
test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
- ...baseAdmin,
+ ...baseSettings,
https: true,
certificateType: "letsencrypt",
},
@@ -99,7 +85,7 @@ test("Should apply redirect-to-https", () => {
});
test("Should change only host when no certificate", () => {
- updateServerTraefik(baseAdmin, "example.com");
+ updateServerTraefik(baseSettings, "example.com");
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -109,7 +95,7 @@ test("Should change only host when no certificate", () => {
test("Should not touch config without host", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
- updateServerTraefik(baseAdmin, null);
+ updateServerTraefik(baseSettings, null);
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -118,11 +104,14 @@ test("Should not touch config without host", () => {
test("Should remove websecure if https rollback to http", () => {
updateServerTraefik(
- { ...baseAdmin, certificateType: "letsencrypt" },
+ { ...baseSettings, certificateType: "letsencrypt" },
"example.com",
);
- updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
+ updateServerTraefik(
+ { ...baseSettings, certificateType: "none" },
+ "example.com",
+ );
const config: FileConfig = loadOrCreateConfig("dokploy");
diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts
index 7b39eeb4f..0e6e529b0 100644
--- a/apps/dokploy/__test__/traefik/traefik.test.ts
+++ b/apps/dokploy/__test__/traefik/traefik.test.ts
@@ -3,18 +3,28 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
- railpackVersion: "0.2.2",
+ railpackVersion: "0.15.4",
rollbackActive: false,
applicationId: "",
previewLabels: [],
+ createEnvFile: true,
+ bitbucketRepositorySlug: "",
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
+ buildServerId: "",
+ buildRegistryId: "",
+ buildRegistry: null,
giteaBuildPath: "",
giteaId: "",
+ args: [],
+ rollbackRegistryId: "",
+ rollbackRegistry: null,
+ deployments: [],
cleanCache: false,
applicationStatus: "done",
+ endpointSpecSwarm: null,
appName: "",
autoDeploy: true,
enableSubmodules: false,
@@ -41,6 +51,7 @@ const baseApp: ApplicationNested = {
environmentId: "",
environment: {
env: "",
+ isDefault: false,
environmentId: "",
name: "",
createdAt: "",
diff --git a/apps/dokploy/__test__/vitest.config.ts b/apps/dokploy/__test__/vitest.config.ts
index ddc84d6ac..7270b828a 100644
--- a/apps/dokploy/__test__/vitest.config.ts
+++ b/apps/dokploy/__test__/vitest.config.ts
@@ -13,7 +13,11 @@ export default defineConfig({
NODE: "test",
},
},
- plugins: [tsconfigPaths()],
+ plugins: [
+ tsconfigPaths({
+ projects: [path.resolve(__dirname, "../tsconfig.json")],
+ }),
+ ],
resolve: {
alias: {
"@dokploy/server": path.resolve(
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx
index 4227eeb44..ee427feca 100644
--- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx
@@ -1,186 +1,106 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { HelpCircle, Settings } from "lucide-react";
-import { useEffect } from "react";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-import { z } from "zod";
+import { Settings } from "lucide-react";
+import { useState } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
-import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
- DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { api } from "@/utils/api";
+import { cn } from "@/lib/utils";
+import {
+ EndpointSpecForm,
+ HealthCheckForm,
+ LabelsForm,
+ ModeForm,
+ PlacementForm,
+ RestartPolicyForm,
+ RollbackConfigForm,
+ StopGracePeriodForm,
+ UpdateConfigForm,
+} from "./swarm-forms";
-const HealthCheckSwarmSchema = z
- .object({
- Test: z.array(z.string()).optional(),
- Interval: z.number().optional(),
- Timeout: z.number().optional(),
- StartPeriod: z.number().optional(),
- Retries: z.number().optional(),
- })
- .strict();
-
-const RestartPolicySwarmSchema = z
- .object({
- Condition: z.string().optional(),
- Delay: z.number().optional(),
- MaxAttempts: z.number().optional(),
- Window: z.number().optional(),
- })
- .strict();
-
-const PreferenceSchema = z
- .object({
- Spread: z.object({
- SpreadDescriptor: z.string(),
- }),
- })
- .strict();
-
-const PlatformSchema = z
- .object({
- Architecture: z.string(),
- OS: z.string(),
- })
- .strict();
-
-const PlacementSwarmSchema = z
- .object({
- Constraints: z.array(z.string()).optional(),
- Preferences: z.array(PreferenceSchema).optional(),
- MaxReplicas: z.number().optional(),
- Platforms: z.array(PlatformSchema).optional(),
- })
- .strict();
-
-const UpdateConfigSwarmSchema = z
- .object({
- Parallelism: z.number(),
- Delay: z.number().optional(),
- FailureAction: z.string().optional(),
- Monitor: z.number().optional(),
- MaxFailureRatio: z.number().optional(),
- Order: z.string(),
- })
- .strict();
-
-const ReplicatedSchema = z
- .object({
- Replicas: z.number().optional(),
- })
- .strict();
-
-const ReplicatedJobSchema = z
- .object({
- MaxConcurrent: z.number().optional(),
- TotalCompletions: z.number().optional(),
- })
- .strict();
-
-const ServiceModeSwarmSchema = z
- .object({
- Replicated: ReplicatedSchema.optional(),
- Global: z.object({}).optional(),
- ReplicatedJob: ReplicatedJobSchema.optional(),
- GlobalJob: z.object({}).optional(),
- })
- .strict();
-
-const NetworkSwarmSchema = z.array(
- z
- .object({
- Target: z.string().optional(),
- Aliases: z.array(z.string()).optional(),
- DriverOpts: z.object({}).optional(),
- })
- .strict(),
-);
-
-const LabelsSwarmSchema = z.record(z.string());
-
-const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
- return z
- .string()
- .transform((str, ctx) => {
- if (str === null || str === "") {
- return null;
- }
- try {
- return JSON.parse(str);
- } catch {
- ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
- return z.NEVER;
- }
- })
- .superRefine((data, ctx) => {
- if (data === null) {
- return;
- }
-
- if (Object.keys(data).length === 0) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Object cannot be empty",
- });
- return;
- }
-
- const parseResult = schema.safeParse(data);
- if (!parseResult.success) {
- for (const error of parseResult.error.issues) {
- const path = error.path.join(".");
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: `${path} ${error.message}`,
- });
- }
- }
- });
+type MenuItem = {
+ id: string;
+ label: string;
+ description: string;
+ docDescription: string;
};
-const addSwarmSettings = z.object({
- healthCheckSwarm: createStringToJSONSchema(HealthCheckSwarmSchema).nullable(),
- restartPolicySwarm: createStringToJSONSchema(
- RestartPolicySwarmSchema,
- ).nullable(),
- placementSwarm: createStringToJSONSchema(PlacementSwarmSchema).nullable(),
- updateConfigSwarm: createStringToJSONSchema(
- UpdateConfigSwarmSchema,
- ).nullable(),
- rollbackConfigSwarm: createStringToJSONSchema(
- UpdateConfigSwarmSchema,
- ).nullable(),
- modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
- labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
- networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
- stopGracePeriodSwarm: z.bigint().nullable(),
-});
-
-type AddSwarmSettings = z.infer;
+const menuItems: MenuItem[] = [
+ {
+ id: "health-check",
+ label: "Health Check",
+ description: "Configure health check settings",
+ docDescription:
+ "Configure HEALTHCHECK to test a container's health. Determines if a container is healthy by running a command inside the container. Test, Interval, Timeout, StartPeriod, and Retries control health monitoring.",
+ },
+ {
+ id: "restart-policy",
+ label: "Restart Policy",
+ description: "Configure restart policy",
+ docDescription:
+ "Configure the restart policy for containers in the service. Condition (none, on-failure, any), Delay (nanoseconds between restarts), MaxAttempts, and Window control restart behavior.",
+ },
+ {
+ id: "placement",
+ label: "Placement",
+ description: "Configure placement constraints",
+ docDescription:
+ "Control which nodes service tasks can be scheduled on. Constraints (node.id==xyz), Preferences (spread.node.labels.zone), MaxReplicas, and Platforms specify task placement rules.",
+ },
+ {
+ id: "update-config",
+ label: "Update Config",
+ description: "Configure update strategy",
+ docDescription:
+ "Configure how the service should be updated. Parallelism (tasks updated simultaneously), Delay, FailureAction (pause, continue, rollback), Monitor, MaxFailureRatio, and Order (stop-first, start-first) control updates.",
+ },
+ {
+ id: "rollback-config",
+ label: "Rollback Config",
+ description: "Configure rollback strategy",
+ docDescription:
+ "Configure automated rollback on update failure. Uses same parameters as UpdateConfig: Parallelism, Delay, FailureAction, Monitor, MaxFailureRatio, and Order.",
+ },
+ {
+ id: "mode",
+ label: "Mode",
+ description: "Configure service mode",
+ docDescription:
+ "Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).",
+ },
+ {
+ id: "labels",
+ label: "Labels",
+ description: "Configure service labels",
+ docDescription:
+ "Add metadata to services using labels. Labels are key-value pairs (e.g., com.example.foo=bar) for organizing and filtering services.",
+ },
+ {
+ id: "stop-grace-period",
+ label: "Stop Grace Period",
+ description: "Configure stop grace period",
+ docDescription:
+ "Time to wait before forcefully killing a container. Specified in nanoseconds (e.g., 10000000000 = 10 seconds). Allows containers to shutdown gracefully.",
+ },
+ {
+ id: "endpoint-spec",
+ label: "Endpoint Spec",
+ description: "Configure endpoint specification",
+ docDescription:
+ "Configure endpoint mode for service discovery. Mode 'vip' (virtual IP - default) uses a single virtual IP. Mode 'dnsrr' (DNS round-robin) returns DNS entries for all tasks.",
+ },
+];
const hasStopGracePeriodSwarm = (
value: unknown,
@@ -195,132 +115,23 @@ interface Props {
}
export const AddSwarmSettings = ({ id, type }: Props) => {
- const queryMap = {
- postgres: () =>
- api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
- redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
- mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
- mariadb: () =>
- api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
- application: () =>
- api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
- mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
- };
- const { data, refetch } = queryMap[type]
- ? queryMap[type]()
- : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
-
- const mutationMap = {
- postgres: () => api.postgres.update.useMutation(),
- redis: () => api.redis.update.useMutation(),
- mysql: () => api.mysql.update.useMutation(),
- mariadb: () => api.mariadb.update.useMutation(),
- application: () => api.application.update.useMutation(),
- mongo: () => api.mongo.update.useMutation(),
- };
-
- const { mutateAsync, isError, error, isLoading } = mutationMap[type]
- ? mutationMap[type]()
- : api.mongo.update.useMutation();
-
- const form = useForm({
- defaultValues: {
- healthCheckSwarm: null,
- restartPolicySwarm: null,
- placementSwarm: null,
- updateConfigSwarm: null,
- rollbackConfigSwarm: null,
- modeSwarm: null,
- labelsSwarm: null,
- networkSwarm: null,
- stopGracePeriodSwarm: null,
- },
- resolver: zodResolver(addSwarmSettings),
- });
-
- useEffect(() => {
- if (data) {
- const stopGracePeriodValue = hasStopGracePeriodSwarm(data)
- ? data.stopGracePeriodSwarm
- : null;
- const normalizedStopGracePeriod =
- stopGracePeriodValue === null || stopGracePeriodValue === undefined
- ? null
- : typeof stopGracePeriodValue === "bigint"
- ? stopGracePeriodValue
- : BigInt(stopGracePeriodValue);
- form.reset({
- healthCheckSwarm: data.healthCheckSwarm
- ? JSON.stringify(data.healthCheckSwarm, null, 2)
- : null,
- restartPolicySwarm: data.restartPolicySwarm
- ? JSON.stringify(data.restartPolicySwarm, null, 2)
- : null,
- placementSwarm: data.placementSwarm
- ? JSON.stringify(data.placementSwarm, null, 2)
- : null,
- updateConfigSwarm: data.updateConfigSwarm
- ? JSON.stringify(data.updateConfigSwarm, null, 2)
- : null,
- rollbackConfigSwarm: data.rollbackConfigSwarm
- ? JSON.stringify(data.rollbackConfigSwarm, null, 2)
- : null,
- modeSwarm: data.modeSwarm
- ? JSON.stringify(data.modeSwarm, null, 2)
- : null,
- labelsSwarm: data.labelsSwarm
- ? JSON.stringify(data.labelsSwarm, null, 2)
- : null,
- networkSwarm: data.networkSwarm
- ? JSON.stringify(data.networkSwarm, null, 2)
- : null,
- stopGracePeriodSwarm: normalizedStopGracePeriod,
- });
- }
- }, [form, form.reset, data]);
-
- const onSubmit = async (data: AddSwarmSettings) => {
- await mutateAsync({
- applicationId: id || "",
- postgresId: id || "",
- redisId: id || "",
- mysqlId: id || "",
- mariadbId: id || "",
- mongoId: id || "",
- healthCheckSwarm: data.healthCheckSwarm,
- restartPolicySwarm: data.restartPolicySwarm,
- placementSwarm: data.placementSwarm,
- updateConfigSwarm: data.updateConfigSwarm,
- rollbackConfigSwarm: data.rollbackConfigSwarm,
- modeSwarm: data.modeSwarm,
- labelsSwarm: data.labelsSwarm,
- networkSwarm: data.networkSwarm,
- stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
- })
- .then(async () => {
- toast.success("Swarm settings updated");
- refetch();
- })
- .catch(() => {
- toast.error("Error updating the swarm settings");
- });
- };
+ const [activeMenu, setActiveMenu] = useState("health-check");
+ const [open, setOpen] = useState(false);
return (
-
+
Swarm Settings
-
+
Swarm Settings
- Update certain settings using a json object.
+ Configure swarm settings for your service.
- {isError && {error?.message} }
Changing settings such as placements may cause the logs/monitoring,
@@ -328,535 +139,66 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
-
-
+ {/* Right Column - Form */}
+
+ {activeMenu === "health-check" && (
+
+ )}
+ {activeMenu === "restart-policy" && (
+
+ )}
+ {activeMenu === "placement" && (
+
+ )}
+ {activeMenu === "update-config" && (
+
+ )}
+ {activeMenu === "rollback-config" && (
+
+ )}
+ {activeMenu === "mode" &&
}
+ {activeMenu === "labels" &&
}
+ {activeMenu === "stop-grace-period" && (
+
+ )}
+ {activeMenu === "endpoint-spec" && (
+
+ )}
+
+
);
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx
new file mode 100644
index 000000000..7ee31e5b6
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx
@@ -0,0 +1,154 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+export const endpointSpecFormSchema = z.object({
+ Mode: z.string().optional(),
+});
+
+interface EndpointSpecFormProps {
+ id: string;
+ type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
+}
+
+export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const queryMap = {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ application: () =>
+ api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
+ mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ };
+ const { data, refetch } = queryMap[type]
+ ? queryMap[type]()
+ : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
+
+ const mutationMap = {
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ mariadb: () => api.mariadb.update.useMutation(),
+ application: () => api.application.update.useMutation(),
+ mongo: () => api.mongo.update.useMutation(),
+ };
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.mongo.update.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(endpointSpecFormSchema),
+ defaultValues: {
+ Mode: undefined,
+ },
+ });
+
+ useEffect(() => {
+ if (data?.endpointSpecSwarm) {
+ const es = data.endpointSpecSwarm;
+ form.reset({
+ Mode: es.Mode,
+ });
+ }
+ }, [data, form]);
+
+ const onSubmit = async (formData: z.infer) => {
+ setIsLoading(true);
+ try {
+ // Check if all values are empty, if so, send null to clear the database
+ const hasAnyValue =
+ formData.Mode !== undefined &&
+ formData.Mode !== null &&
+ formData.Mode !== "";
+
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ endpointSpecSwarm: hasAnyValue ? formData : null,
+ });
+
+ toast.success("Endpoint spec updated successfully");
+ refetch();
+ } catch {
+ toast.error("Error updating endpoint spec");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx
new file mode 100644
index 000000000..b2fc49ef3
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx
@@ -0,0 +1,267 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { api } from "@/utils/api";
+
+export const healthCheckFormSchema = z.object({
+ Test: z.array(z.string()).optional(),
+ Interval: z.coerce.number().optional(),
+ Timeout: z.coerce.number().optional(),
+ StartPeriod: z.coerce.number().optional(),
+ Retries: z.coerce.number().optional(),
+});
+
+interface HealthCheckFormProps {
+ id: string;
+ type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
+}
+
+export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [testCommands, setTestCommands] = useState([]);
+
+ const queryMap = {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ application: () =>
+ api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
+ mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ };
+ const { data, refetch } = queryMap[type]
+ ? queryMap[type]()
+ : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
+
+ const mutationMap = {
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ mariadb: () => api.mariadb.update.useMutation(),
+ application: () => api.application.update.useMutation(),
+ mongo: () => api.mongo.update.useMutation(),
+ };
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.mongo.update.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(healthCheckFormSchema),
+ defaultValues: {
+ Test: [],
+ Interval: undefined,
+ Timeout: undefined,
+ StartPeriod: undefined,
+ Retries: undefined,
+ },
+ });
+
+ useEffect(() => {
+ if (data?.healthCheckSwarm) {
+ const hc = data.healthCheckSwarm;
+ form.reset({
+ Test: hc.Test || [],
+ Interval: hc.Interval,
+ Timeout: hc.Timeout,
+ StartPeriod: hc.StartPeriod,
+ Retries: hc.Retries,
+ });
+ setTestCommands(hc.Test || []);
+ }
+ }, [data, form]);
+
+ const onSubmit = async (formData: z.infer) => {
+ setIsLoading(true);
+ try {
+ // Check if all values are empty, if so, send null to clear the database
+ const hasAnyValue =
+ (formData.Test && formData.Test.length > 0) ||
+ formData.Interval !== undefined ||
+ formData.Timeout !== undefined ||
+ formData.StartPeriod !== undefined ||
+ formData.Retries !== undefined;
+
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ healthCheckSwarm: hasAnyValue ? formData : null,
+ });
+
+ toast.success("Health check updated successfully");
+ refetch();
+ } catch {
+ toast.error("Error updating health check");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const addTestCommand = () => {
+ setTestCommands([...testCommands, ""]);
+ };
+
+ const updateTestCommand = (index: number, value: string) => {
+ const newCommands = [...testCommands];
+ newCommands[index] = value;
+ setTestCommands(newCommands);
+ };
+
+ const removeTestCommand = (index: number) => {
+ setTestCommands(testCommands.filter((_, i) => i !== index));
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts
new file mode 100644
index 000000000..ebd00abcd
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts
@@ -0,0 +1,10 @@
+export { HealthCheckForm } from "./health-check-form";
+export { RestartPolicyForm } from "./restart-policy-form";
+export { PlacementForm } from "./placement-form";
+export { UpdateConfigForm } from "./update-config-form";
+export { RollbackConfigForm } from "./rollback-config-form";
+export { ModeForm } from "./mode-form";
+export { LabelsForm } from "./labels-form";
+export { StopGracePeriodForm } from "./stop-grace-period-form";
+export { EndpointSpecForm } from "./endpoint-spec-form";
+export { filterEmptyValues, hasValues } from "./utils";
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx
new file mode 100644
index 000000000..d1681dcd0
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx
@@ -0,0 +1,200 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useFieldArray, useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { api } from "@/utils/api";
+
+export const labelsFormSchema = z.object({
+ labels: z
+ .array(
+ z.object({
+ key: z.string(),
+ value: z.string(),
+ }),
+ )
+ .optional(),
+});
+
+interface LabelsFormProps {
+ id: string;
+ type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
+}
+
+export const LabelsForm = ({ id, type }: LabelsFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const queryMap = {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ application: () =>
+ api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
+ mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ };
+ const { data, refetch } = queryMap[type]
+ ? queryMap[type]()
+ : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
+
+ const mutationMap = {
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ mariadb: () => api.mariadb.update.useMutation(),
+ application: () => api.application.update.useMutation(),
+ mongo: () => api.mongo.update.useMutation(),
+ };
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.mongo.update.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(labelsFormSchema),
+ defaultValues: {
+ labels: [],
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "labels",
+ });
+
+ useEffect(() => {
+ if (data?.labelsSwarm && typeof data.labelsSwarm === "object") {
+ const labelEntries = Object.entries(data.labelsSwarm).map(
+ ([key, value]) => ({
+ key,
+ value: value as string,
+ }),
+ );
+ form.reset({ labels: labelEntries });
+ }
+ }, [data, form]);
+
+ const onSubmit = async (formData: z.infer) => {
+ setIsLoading(true);
+ try {
+ const labelsObject =
+ formData.labels?.reduce(
+ (acc, { key, value }) => {
+ if (key && value) {
+ acc[key] = value;
+ }
+ return acc;
+ },
+ {} as Record,
+ ) || {};
+
+ // If no labels, send null to clear the database
+ const labelsToSend =
+ Object.keys(labelsObject).length > 0 ? labelsObject : null;
+
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ labelsSwarm: labelsToSend,
+ });
+
+ toast.success("Labels updated successfully");
+ refetch();
+ } catch {
+ toast.error("Error updating labels");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx
new file mode 100644
index 000000000..839f5d519
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx
@@ -0,0 +1,195 @@
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+interface ModeFormProps {
+ id: string;
+ type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
+}
+
+export const ModeForm = ({ id, type }: ModeFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const queryMap = {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ application: () =>
+ api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
+ mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ };
+ const { data, refetch } = queryMap[type]
+ ? queryMap[type]()
+ : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
+
+ const mutationMap = {
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ mariadb: () => api.mariadb.update.useMutation(),
+ application: () => api.application.update.useMutation(),
+ mongo: () => api.mongo.update.useMutation(),
+ };
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.mongo.update.useMutation();
+
+ const form = useForm({
+ defaultValues: {
+ type: undefined,
+ Replicas: undefined,
+ },
+ });
+
+ const modeType = form.watch("type");
+
+ useEffect(() => {
+ if (data?.modeSwarm) {
+ const mode = data.modeSwarm;
+ if (mode.Replicated) {
+ form.reset({
+ type: "Replicated",
+ Replicas: mode.Replicated.Replicas,
+ });
+ } else if (mode.Global) {
+ form.reset({
+ type: "Global",
+ Replicas: undefined,
+ });
+ }
+ }
+ }, [data, form]);
+
+ const onSubmit = async (formData: any) => {
+ setIsLoading(true);
+ try {
+ // If no type is selected, send null to clear the database
+ if (!formData.type) {
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ modeSwarm: null,
+ });
+ toast.success("Mode updated successfully");
+ refetch();
+ setIsLoading(false);
+ return;
+ }
+
+ const modeData =
+ formData.type === "Replicated"
+ ? { Replicated: { Replicas: formData.Replicas } }
+ : { Global: {} };
+
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ modeSwarm: modeData,
+ });
+
+ toast.success("Mode updated successfully");
+ refetch();
+ } catch {
+ toast.error("Error updating mode");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx
new file mode 100644
index 000000000..b0c354513
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx
@@ -0,0 +1,342 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { api } from "@/utils/api";
+
+const PreferenceSchema = z.object({
+ Spread: z.object({
+ SpreadDescriptor: z.string(),
+ }),
+});
+
+const PlatformSchema = z.object({
+ Architecture: z.string(),
+ OS: z.string(),
+});
+
+export const placementFormSchema = z.object({
+ Constraints: z.array(z.string()).optional(),
+ Preferences: z.array(PreferenceSchema).optional(),
+ MaxReplicas: z.coerce.number().optional(),
+ Platforms: z.array(PlatformSchema).optional(),
+});
+
+interface PlacementFormProps {
+ id: string;
+ type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
+}
+
+export const PlacementForm = ({ id, type }: PlacementFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const queryMap = {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ application: () =>
+ api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
+ mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ };
+ const { data, refetch } = queryMap[type]
+ ? queryMap[type]()
+ : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
+
+ const mutationMap = {
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ mariadb: () => api.mariadb.update.useMutation(),
+ application: () => api.application.update.useMutation(),
+ mongo: () => api.mongo.update.useMutation(),
+ };
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.mongo.update.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(placementFormSchema),
+ defaultValues: {
+ Constraints: [],
+ Preferences: [],
+ MaxReplicas: undefined,
+ Platforms: [],
+ },
+ });
+
+ const constraints = form.watch("Constraints") || [];
+ const preferences = form.watch("Preferences") || [];
+ const platforms = form.watch("Platforms") || [];
+
+ useEffect(() => {
+ if (data?.placementSwarm) {
+ const placement = data.placementSwarm;
+ form.reset({
+ Constraints: placement.Constraints || [],
+ Preferences:
+ placement.Preferences?.map((p: any) => ({
+ SpreadDescriptor: p.Spread?.SpreadDescriptor || "",
+ })) || [],
+ MaxReplicas: placement.MaxReplicas,
+ Platforms: placement.Platforms || [],
+ });
+ }
+ }, [data, form]);
+
+ const onSubmit = async (formData: z.infer) => {
+ setIsLoading(true);
+ try {
+ // Check if all values are empty, if so, send null to clear the database
+ const hasAnyValue =
+ (formData.Constraints && formData.Constraints.length > 0) ||
+ (formData.Preferences && formData.Preferences.length > 0) ||
+ (formData.Platforms && formData.Platforms.length > 0) ||
+ formData.MaxReplicas !== undefined;
+
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ placementSwarm: hasAnyValue ? formData : null,
+ });
+
+ toast.success("Placement updated successfully");
+ refetch();
+ } catch {
+ toast.error("Error updating placement");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const addConstraint = () => {
+ form.setValue("Constraints", [...constraints, ""]);
+ };
+
+ const updateConstraint = (index: number, value: string) => {
+ const newConstraints = [...constraints];
+ newConstraints[index] = value;
+ form.setValue("Constraints", newConstraints);
+ };
+
+ const removeConstraint = (index: number) => {
+ form.setValue(
+ "Constraints",
+ constraints.filter((_: string, i: number) => i !== index),
+ );
+ };
+
+ const addPreference = () => {
+ form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]);
+ };
+
+ const updatePreference = (index: number, value: string) => {
+ const newPreferences = [...preferences];
+ if (newPreferences[index]) {
+ newPreferences[index].SpreadDescriptor = value;
+ form.setValue("Preferences", newPreferences);
+ }
+ };
+
+ const removePreference = (index: number) => {
+ form.setValue(
+ "Preferences",
+ preferences.filter((_: any, i: number) => i !== index),
+ );
+ };
+
+ const addPlatform = () => {
+ form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]);
+ };
+
+ const updatePlatform = (
+ index: number,
+ field: "Architecture" | "OS",
+ value: string,
+ ) => {
+ const newPlatforms = [...platforms];
+ if (newPlatforms[index]) {
+ newPlatforms[index][field] = value;
+ form.setValue("Platforms", newPlatforms);
+ }
+ };
+
+ const removePlatform = (index: number) => {
+ form.setValue(
+ "Platforms",
+ platforms.filter((_: any, i: number) => i !== index),
+ );
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx
new file mode 100644
index 000000000..b7fb649be
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx
@@ -0,0 +1,219 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+export const restartPolicyFormSchema = z.object({
+ Condition: z.string().optional(),
+ Delay: z.coerce.number().optional(),
+ MaxAttempts: z.coerce.number().optional(),
+ Window: z.coerce.number().optional(),
+});
+
+interface RestartPolicyFormProps {
+ id: string;
+ type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
+}
+
+export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const queryMap = {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ application: () =>
+ api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
+ mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ };
+ const { data, refetch } = queryMap[type]
+ ? queryMap[type]()
+ : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
+
+ const mutationMap = {
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ mariadb: () => api.mariadb.update.useMutation(),
+ application: () => api.application.update.useMutation(),
+ mongo: () => api.mongo.update.useMutation(),
+ };
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.mongo.update.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(restartPolicyFormSchema),
+ defaultValues: {
+ Condition: undefined,
+ Delay: undefined,
+ MaxAttempts: undefined,
+ Window: undefined,
+ },
+ });
+
+ useEffect(() => {
+ if (data?.restartPolicySwarm) {
+ form.reset({
+ Condition: data.restartPolicySwarm.Condition,
+ Delay: data.restartPolicySwarm.Delay,
+ MaxAttempts: data.restartPolicySwarm.MaxAttempts,
+ Window: data.restartPolicySwarm.Window,
+ });
+ }
+ }, [data, form]);
+
+ const onSubmit = async (
+ formData: z.infer,
+ ) => {
+ setIsLoading(true);
+ try {
+ // Check if all values are empty, if so, send null to clear the database
+ const hasAnyValue = Object.values(formData).some(
+ (value) => value !== undefined && value !== null && value !== "",
+ );
+
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ restartPolicySwarm: hasAnyValue ? formData : null,
+ });
+
+ toast.success("Restart policy updated successfully");
+ refetch();
+ } catch {
+ toast.error("Error updating restart policy");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx
new file mode 100644
index 000000000..d53215348
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx
@@ -0,0 +1,257 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+export const rollbackConfigFormSchema = z.object({
+ Parallelism: z.coerce.number().optional(),
+ Delay: z.coerce.number().optional(),
+ FailureAction: z.string().optional(),
+ Monitor: z.coerce.number().optional(),
+ MaxFailureRatio: z.coerce.number().optional(),
+ Order: z.string().optional(),
+});
+
+interface RollbackConfigFormProps {
+ id: string;
+ type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
+}
+
+export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const queryMap = {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ application: () =>
+ api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
+ mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ };
+ const { data, refetch } = queryMap[type]
+ ? queryMap[type]()
+ : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
+
+ const mutationMap = {
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ mariadb: () => api.mariadb.update.useMutation(),
+ application: () => api.application.update.useMutation(),
+ mongo: () => api.mongo.update.useMutation(),
+ };
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.mongo.update.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(rollbackConfigFormSchema),
+ defaultValues: {
+ Parallelism: undefined,
+ Delay: undefined,
+ FailureAction: undefined,
+ Monitor: undefined,
+ MaxFailureRatio: undefined,
+ Order: undefined,
+ },
+ });
+
+ useEffect(() => {
+ if (data?.rollbackConfigSwarm) {
+ form.reset(data.rollbackConfigSwarm);
+ }
+ }, [data, form]);
+
+ const onSubmit = async (
+ formData: z.infer,
+ ) => {
+ setIsLoading(true);
+ try {
+ // Check if all values are empty, if so, send null to clear the database
+ const hasAnyValue = Object.values(formData).some(
+ (value) => value !== undefined && value !== null && value !== "",
+ );
+
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
+ });
+
+ toast.success("Rollback config updated successfully");
+ refetch();
+ } catch {
+ toast.error("Error updating rollback config");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx
new file mode 100644
index 000000000..a324da31b
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx
@@ -0,0 +1,158 @@
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { api } from "@/utils/api";
+
+const hasStopGracePeriodSwarm = (
+ value: unknown,
+): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
+ typeof value === "object" &&
+ value !== null &&
+ "stopGracePeriodSwarm" in value;
+
+interface StopGracePeriodFormProps {
+ id: string;
+ type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
+}
+
+export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const queryMap = {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ application: () =>
+ api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
+ mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ };
+ const { data, refetch } = queryMap[type]
+ ? queryMap[type]()
+ : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
+
+ const mutationMap = {
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ mariadb: () => api.mariadb.update.useMutation(),
+ application: () => api.application.update.useMutation(),
+ mongo: () => api.mongo.update.useMutation(),
+ };
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.mongo.update.useMutation();
+
+ const form = useForm({
+ defaultValues: {
+ value: null as bigint | null,
+ },
+ });
+
+ useEffect(() => {
+ if (hasStopGracePeriodSwarm(data)) {
+ const value = data.stopGracePeriodSwarm;
+ const normalizedValue =
+ value === null || value === undefined
+ ? null
+ : typeof value === "bigint"
+ ? value
+ : BigInt(value);
+ form.reset({
+ value: normalizedValue,
+ });
+ }
+ }, [data, form]);
+
+ const onSubmit = async (formData: any) => {
+ setIsLoading(true);
+ try {
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ stopGracePeriodSwarm: formData.value,
+ });
+
+ toast.success("Stop grace period updated successfully");
+ refetch();
+ } catch {
+ toast.error("Error updating stop grace period");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx
new file mode 100644
index 000000000..4119c41f8
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx
@@ -0,0 +1,264 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+export const updateConfigFormSchema = z.object({
+ Parallelism: z.coerce.number().optional(),
+ Delay: z.coerce.number().optional(),
+ FailureAction: z.string().optional(),
+ Monitor: z.coerce.number().optional(),
+ MaxFailureRatio: z.coerce.number().optional(),
+ Order: z.string().optional(),
+});
+
+interface UpdateConfigFormProps {
+ id: string;
+ type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
+}
+
+export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const queryMap = {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ application: () =>
+ api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
+ mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ };
+ const { data, refetch } = queryMap[type]
+ ? queryMap[type]()
+ : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
+
+ const mutationMap = {
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ mariadb: () => api.mariadb.update.useMutation(),
+ application: () => api.application.update.useMutation(),
+ mongo: () => api.mongo.update.useMutation(),
+ };
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.mongo.update.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(updateConfigFormSchema),
+ defaultValues: {
+ Parallelism: undefined,
+ Delay: undefined,
+ FailureAction: undefined,
+ Monitor: undefined,
+ MaxFailureRatio: undefined,
+ Order: undefined,
+ },
+ });
+
+ useEffect(() => {
+ if (data?.updateConfigSwarm) {
+ const config = data.updateConfigSwarm;
+ form.reset({
+ Parallelism: config.Parallelism,
+ Delay: config.Delay,
+ FailureAction: config.FailureAction,
+ Monitor: config.Monitor,
+ MaxFailureRatio: config.MaxFailureRatio,
+ Order: config.Order,
+ });
+ }
+ }, [data, form]);
+
+ const onSubmit = async (formData: z.infer) => {
+ setIsLoading(true);
+ try {
+ // Check if all values are empty, if so, send null to clear the database
+ const hasAnyValue = Object.values(formData).some(
+ (value) => value !== undefined && value !== null && value !== "",
+ );
+
+ await mutateAsync({
+ applicationId: id || "",
+ postgresId: id || "",
+ redisId: id || "",
+ mysqlId: id || "",
+ mariadbId: id || "",
+ mongoId: id || "",
+ updateConfigSwarm: (hasAnyValue ? formData : null) as any,
+ });
+
+ toast.success("Update config updated successfully");
+ refetch();
+ } catch {
+ toast.error("Error updating update config");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts
new file mode 100644
index 000000000..58793c02e
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts
@@ -0,0 +1,31 @@
+/**
+ * Filters out undefined, null, and empty string values from form data
+ * Only returns fields that have actual values
+ */
+export const filterEmptyValues = (
+ formData: Record,
+): Record => {
+ return Object.entries(formData).reduce(
+ (acc, [key, value]) => {
+ // Keep arrays even if empty (they might be intentionally cleared)
+ if (Array.isArray(value)) {
+ if (value.length > 0) {
+ acc[key] = value;
+ }
+ }
+ // For other values, filter out undefined, null, and empty strings
+ else if (value !== undefined && value !== null && value !== "") {
+ acc[key] = value;
+ }
+ return acc;
+ },
+ {} as Record,
+ );
+};
+
+/**
+ * Checks if filtered data has any values to save
+ */
+export const hasValues = (data: Record): boolean => {
+ return Object.keys(data).length > 0;
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx b/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx
index 1bf69394a..a7c5f7288 100644
--- a/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx
@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
+import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
-import { useForm } from "react-hook-form";
+import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +29,13 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
+ args: z
+ .array(
+ z.object({
+ value: z.string().min(1, "Argument cannot be empty"),
+ }),
+ )
+ .optional(),
});
type AddCommand = z.infer;
@@ -47,22 +55,30 @@ export const AddCommand = ({ applicationId }: Props) => {
const form = useForm({
defaultValues: {
command: "",
+ args: [],
},
resolver: zodResolver(AddRedirectSchema),
});
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "args",
+ });
+
useEffect(() => {
- if (data?.command) {
+ if (data) {
form.reset({
command: data?.command || "",
+ args: data?.args?.map((arg) => ({ value: arg })) || [],
});
}
- }, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
+ }, [data, form]);
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId,
command: data?.command,
+ args: data?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Command Updated");
@@ -100,13 +116,65 @@ export const AddCommand = ({ applicationId }: Props) => {
Command
-
+
)}
/>
+
+
+
+
Arguments (Args)
+
append({ value: "" })}
+ >
+
+ Add Argument
+
+
+
+ {fields.length === 0 && (
+
+ No arguments added yet. Click "Add Argument" to add one.
+
+ )}
+
+ {fields.map((field, index) => (
+
(
+
+
+
+
+
+ remove(index)}
+ >
+
+
+
+
+
+ )}
+ />
+ ))}
+
diff --git a/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx b/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx
new file mode 100644
index 000000000..545a5f705
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx
@@ -0,0 +1,286 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Server } from "lucide-react";
+import Link from "next/link";
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+interface Props {
+ applicationId: string;
+}
+
+const schema = z
+ .object({
+ buildServerId: z.string().optional(),
+ buildRegistryId: z.string().optional(),
+ })
+ .refine(
+ (data) => {
+ // Both empty/none is valid
+ const buildServerIsNone =
+ !data.buildServerId || data.buildServerId === "none";
+ const buildRegistryIsNone =
+ !data.buildRegistryId || data.buildRegistryId === "none";
+
+ // Both should be either filled or empty
+ if (buildServerIsNone && buildRegistryIsNone) return true;
+ if (!buildServerIsNone && !buildRegistryIsNone) return true;
+
+ return false;
+ },
+ {
+ message:
+ "Both Build Server and Build Registry must be selected together, or both set to None",
+ path: ["buildServerId"], // Show error on buildServerId field
+ },
+ );
+
+type Schema = z.infer;
+
+export const ShowBuildServer = ({ applicationId }: Props) => {
+ const { data, refetch } = api.application.one.useQuery(
+ { applicationId },
+ { enabled: !!applicationId },
+ );
+ const { data: buildServers } = api.server.buildServers.useQuery();
+ const { data: registries } = api.registry.all.useQuery();
+
+ const { mutateAsync, isLoading } = api.application.update.useMutation();
+
+ const form = useForm({
+ defaultValues: {
+ buildServerId: data?.buildServerId || "",
+ buildRegistryId: data?.buildRegistryId || "",
+ },
+ resolver: zodResolver(schema),
+ });
+
+ useEffect(() => {
+ if (data) {
+ form.reset({
+ buildServerId: data?.buildServerId || "",
+ buildRegistryId: data?.buildRegistryId || "",
+ });
+ }
+ }, [form, form.reset, data]);
+
+ const onSubmit = async (formData: Schema) => {
+ await mutateAsync({
+ applicationId,
+ buildServerId:
+ formData?.buildServerId === "none" || !formData?.buildServerId
+ ? null
+ : formData?.buildServerId,
+ buildRegistryId:
+ formData?.buildRegistryId === "none" || !formData?.buildRegistryId
+ ? null
+ : formData?.buildRegistryId,
+ })
+ .then(async () => {
+ toast.success("Build Server Settings Updated");
+ await refetch();
+ })
+ .catch(() => {
+ toast.error("Error updating build server settings");
+ });
+ };
+
+ return (
+
+
+
+
+
+ Build Server
+
+ Configure a dedicated server for building your application.
+
+
+
+
+
+
+ Build servers offload the build process from your deployment servers.
+ Select a build server and registry to use for building your
+ application.
+
+
+
+ 📊 Important: Once the build finishes, you'll need to
+ wait a few seconds for the deployment server to download the image.
+ These download logs will NOT appear in the build
+ deployment logs. Check the Logs tab to see when the
+ container starts running.
+
+
+
+ Note: Build Server and Build Registry must be
+ configured together. You can either select both or set both to None.
+
+
+ {!registries || registries.length === 0 ? (
+
+ You need to add at least one registry to use build servers. Please
+ go to{" "}
+
+ Settings
+ {" "}
+ to add a registry.
+
+ ) : null}
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx
index 3beedcdbc..aea30e49b 100644
--- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx
@@ -21,7 +21,10 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
+import {
+ createConverter,
+ NumberInputWithSteps,
+} from "@/components/ui/number-input";
import {
Tooltip,
TooltipContent,
@@ -30,6 +33,23 @@ import {
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
+const CPU_STEP = 0.25;
+const MEMORY_STEP_MB = 256;
+
+const formatNumber = (value: number, decimals = 2): string =>
+ Number.isInteger(value) ? String(value) : value.toFixed(decimals);
+
+const cpuConverter = createConverter(1_000_000_000, (cpu) =>
+ cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
+);
+
+const memoryConverter = createConverter(1024 * 1024, (mb) => {
+ if (mb <= 0) return "";
+ return mb >= 1024
+ ? `${formatNumber(mb / 1024)} GB`
+ : `${formatNumber(mb)} MB`;
+});
+
const addResourcesSchema = z.object({
memoryReservation: z.string().optional(),
cpuLimit: z.string().optional(),
@@ -51,6 +71,7 @@ interface Props {
}
type AddResources = z.infer;
+
export const ShowResources = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
@@ -163,16 +184,20 @@ export const ShowResources = ({ id, type }: Props) => {
Memory hard limit in bytes. Example: 1GB =
- 1073741824 bytes
+ 1073741824 bytes. Use +/- buttons to adjust by
+ 256 MB.
-
@@ -198,16 +223,20 @@ export const ShowResources = ({ id, type }: Props) => {
Memory soft limit in bytes. Example: 256MB =
- 268435456 bytes
+ 268435456 bytes. Use +/- buttons to adjust by 256
+ MB.
-
@@ -234,17 +263,20 @@ export const ShowResources = ({ id, type }: Props) => {
CPU quota in units of 10^-9 CPUs. Example: 2
- CPUs = 2000000000
+ CPUs = 2000000000. Use +/- buttons to adjust by
+ 0.25 CPU.
-
@@ -271,14 +303,21 @@ export const ShowResources = ({ id, type }: Props) => {
CPU shares (relative weight). Example: 1 CPU =
- 1000000000
+ 1000000000. Use +/- buttons to adjust by 0.25
+ CPU.
-
+
diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx
index d7621bc1e..2bfd6bbc0 100644
--- a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx
@@ -59,7 +59,13 @@ const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("volume"),
- volumeName: z.string().min(1, "Volume name required"),
+ volumeName: z
+ .string()
+ .min(1, "Volume name required")
+ .regex(
+ /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
+ "Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
+ ),
})
.merge(mountSchema),
z
diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx
index 38d02ec90..44fb050bc 100644
--- a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx
@@ -41,7 +41,13 @@ const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("volume"),
- volumeName: z.string().min(1, "Volume name required"),
+ volumeName: z
+ .string()
+ .min(1, "Volume name required")
+ .regex(
+ /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
+ "Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
+ ),
})
.merge(mountSchema),
z
diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx
index 1a0ed386d..7f92157f2 100644
--- a/apps/dokploy/components/dashboard/application/build/show.tsx
+++ b/apps/dokploy/components/dashboard/application/build/show.tsx
@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Cog } from "lucide-react";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -20,8 +20,39 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import { api } from "@/utils/api";
+// Railpack versions from https://github.com/railwayapp/railpack/releases
+export const RAILPACK_VERSIONS = [
+ "0.15.4",
+ "0.15.3",
+ "0.15.2",
+ "0.15.1",
+ "0.15.0",
+ "0.14.0",
+ "0.13.0",
+ "0.12.0",
+ "0.11.0",
+ "0.10.0",
+ "0.9.2",
+ "0.9.1",
+ "0.9.0",
+ "0.8.0",
+ "0.7.0",
+ "0.6.0",
+ "0.5.0",
+ "0.4.0",
+ "0.3.0",
+ "0.2.2",
+] as const;
+
export enum BuildType {
dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks",
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
}),
z.object({
buildType: z.literal(BuildType.railpack),
- railpackVersion: z.string().nullable().default("0.2.2"),
+ railpackVersion: z.string().nullable().default("0.15.4"),
}),
z.object({
buildType: z.literal(BuildType.static),
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});
const buildType = form.watch("buildType");
+ const railpackVersion = form.watch("railpackVersion");
+ const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
useEffect(() => {
if (data) {
@@ -163,9 +196,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
};
form.reset(resetData(typedData));
+
+ // Check if railpack version is manual (not in the predefined list)
+ if (
+ data.railpackVersion &&
+ !RAILPACK_VERSIONS.includes(data.railpackVersion as any)
+ ) {
+ setIsManualRailpackVersion(true);
+ }
}
}, [data, form]);
+ // Hide builder section when Docker provider is selected
+ if (data?.sourceType === "docker") {
+ return null;
+ }
+
const onSubmit = async (data: AddTemplate) => {
await mutateAsync({
applicationId,
@@ -186,7 +232,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.static ? data.isStaticSpa : null,
railpackVersion:
data.buildType === BuildType.railpack
- ? data.railpackVersion || "0.2.2"
+ ? data.railpackVersion || "0.15.4"
: null,
})
.then(async () => {
@@ -403,23 +449,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
/>
)}
{buildType === BuildType.railpack && (
- (
-
- Railpack Version
-
-
-
-
-
- )}
- />
+ <>
+ (
+
+ Railpack Version
+
+ {isManualRailpackVersion ? (
+
+
+ {
+ setIsManualRailpackVersion(false);
+ field.onChange("0.15.4");
+ }}
+ >
+ Use predefined versions
+
+
+ ) : (
+ {
+ if (value === "manual") {
+ setIsManualRailpackVersion(true);
+ field.onChange("");
+ } else {
+ field.onChange(value);
+ }
+ }}
+ value={field.value ?? "0.15.4"}
+ >
+
+
+
+
+
+
+ ✏️ Manual (Custom Version)
+
+
+ {RAILPACK_VERSIONS.map((version) => (
+
+ v{version}
+ {version === "0.15.4" && (
+
+ Latest
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ Select a Railpack version or choose manual to enter a
+ custom version.{" "}
+
+ View releases
+
+
+
+
+ )}
+ />
+ >
)}
diff --git a/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx b/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx
new file mode 100644
index 000000000..784534dd6
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx
@@ -0,0 +1,65 @@
+import { Scissors } from "lucide-react";
+import { toast } from "sonner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { api } from "@/utils/api";
+
+interface Props {
+ id: string;
+ type: "application" | "compose";
+}
+
+export const KillBuild = ({ id, type }: Props) => {
+ const { mutateAsync, isLoading } =
+ type === "application"
+ ? api.application.killBuild.useMutation()
+ : api.compose.killBuild.useMutation();
+
+ return (
+
+
+
+ Kill Build
+
+
+
+
+
+ Are you sure to kill the build?
+
+ This will kill the build process
+
+
+
+ Cancel
+ {
+ await mutateAsync({
+ applicationId: id || "",
+ composeId: id || "",
+ })
+ .then(() => {
+ toast.success("Build killed successfully");
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ });
+ }}
+ >
+ Confirm
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx
index 1045856c2..cfe747d27 100644
--- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx
+++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx
@@ -1,4 +1,12 @@
-import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
+import {
+ ChevronDown,
+ ChevronUp,
+ Clock,
+ Loader2,
+ RefreshCcw,
+ RocketIcon,
+ Settings,
+} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -17,6 +25,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
+import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -80,6 +89,23 @@ export const ShowDeployments = ({
} = api.compose.cancelDeployment.useMutation();
const [url, setUrl] = React.useState("");
+ const [expandedDescriptions, setExpandedDescriptions] = useState>(
+ new Set(),
+ );
+
+ const MAX_DESCRIPTION_LENGTH = 200;
+
+ const truncateDescription = (description: string): string => {
+ if (description.length <= MAX_DESCRIPTION_LENGTH) {
+ return description;
+ }
+ const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH);
+ const lastSpace = truncated.lastIndexOf(" ");
+ if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) {
+ return `${truncated.slice(0, lastSpace)}...`;
+ }
+ return `${truncated}...`;
+ };
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
const stuckDeployment = useMemo(() => {
@@ -117,7 +143,10 @@ export const ShowDeployments = ({
See the last 10 deployments for this {type}
-
+
+ {(type === "application" || type === "compose") && (
+
+ )}
{(type === "application" || type === "compose") && (
)}
@@ -217,122 +246,183 @@ export const ShowDeployments = ({
) : (
- {deployments?.map((deployment, index) => (
-
-
-
- {index + 1}. {deployment.status}
-
-
-
- {deployment.title}
-
- {deployment.description && (
-
- {deployment.description}
+ {deployments?.map((deployment, index) => {
+ const titleText = deployment?.title?.trim() || "";
+ const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
+ const isExpanded = expandedDescriptions.has(
+ deployment.deploymentId,
+ );
+
+ return (
+
+
+
+ {index + 1}. {deployment.status}
+
- )}
-
-
-
-
- {deployment.startedAt && deployment.finishedAt && (
-
-
- {formatDuration(
- Math.floor(
- (new Date(deployment.finishedAt).getTime() -
- new Date(deployment.startedAt).getTime()) /
- 1000,
- ),
- )}
-
- )}
-
-
- {deployment.pid && deployment.status === "running" && (
- {
- await killProcess({
- deploymentId: deployment.deploymentId,
- })
- .then(() => {
- toast.success("Process killed successfully");
- })
- .catch(() => {
- toast.error("Error killing process");
- });
- }}
- >
-
+
+ {isExpanded || !needsTruncation
+ ? titleText
+ : truncateDescription(titleText)}
+
+ {needsTruncation && (
+ {
+ const next = new Set(expandedDescriptions);
+ if (next.has(deployment.deploymentId)) {
+ next.delete(deployment.deploymentId);
+ } else {
+ next.add(deployment.deploymentId);
+ }
+ setExpandedDescriptions(next);
+ }}
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mt-1 cursor-pointer"
+ aria-label={
+ isExpanded
+ ? "Collapse commit message"
+ : "Expand commit message"
+ }
>
- Kill Process
-
-
- )}
- {
- setActiveLog(deployment);
- }}
- >
- View
-
+ {isExpanded ? (
+ <>
+
+ Show less
+ >
+ ) : (
+ <>
+
+ Show more
+ >
+ )}
+
+ )}
+ {/* Hash (from description) - shown in compact form */}
+ {deployment.description?.trim() && (
+
+ {deployment.description}
+
+ )}
+
+
+
+
+
+ {deployment.startedAt && deployment.finishedAt && (
+
+
+ {formatDuration(
+ Math.floor(
+ (new Date(deployment.finishedAt).getTime() -
+ new Date(deployment.startedAt).getTime()) /
+ 1000,
+ ),
+ )}
+
+ )}
+
- {deployment?.rollback &&
- deployment.status === "done" &&
- type === "application" && (
+
+ {deployment.pid && deployment.status === "running" && (
{
- await rollback({
- rollbackId: deployment.rollback.rollbackId,
+ await killProcess({
+ deploymentId: deployment.deploymentId,
})
.then(() => {
- toast.success(
- "Rollback initiated successfully",
- );
+ toast.success("Process killed successfully");
})
.catch(() => {
- toast.error("Error initiating rollback");
+ toast.error("Error killing process");
});
}}
>
-
- Rollback
+ Kill Process
)}
+
{
+ setActiveLog(deployment);
+ }}
+ className="w-full sm:w-auto"
+ >
+ View
+
+
+ {deployment?.rollback &&
+ deployment.status === "done" &&
+ type === "application" && (
+
+
+ Are you sure you want to rollback to this
+ deployment?
+
+
+ Please wait a few seconds while the image is
+ pulled from the registry. Your application
+ should be running shortly.
+
+
+ }
+ type="default"
+ onClick={async () => {
+ await rollback({
+ rollbackId: deployment.rollback.rollbackId,
+ })
+ .then(() => {
+ toast.success(
+ "Rollback initiated successfully",
+ );
+ })
+ .catch(() => {
+ toast.error("Error initiating rollback");
+ });
+ }}
+ >
+
+
+ Rollback
+
+
+ )}
+
-
- ))}
+ );
+ })}
)}
setActiveLog(null)}
logPath={activeLog?.logPath || ""}
diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
index 9d7a074f9..6af0e1e8c 100644
--- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
+++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
@@ -46,7 +46,13 @@ export type CacheType = "fetch" | "cache";
export const domain = z
.object({
- host: z.string().min(1, { message: "Add a hostname" }),
+ host: z
+ .string()
+ .min(1, { message: "Add a hostname" })
+ .refine((val) => val === val.trim(), {
+ message: "Domain name cannot have leading or trailing spaces",
+ })
+ .transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
@@ -202,6 +208,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const certificateType = form.watch("certificateType");
const https = form.watch("https");
const domainType = form.watch("domainType");
+ const host = form.watch("host");
+ const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -299,6 +307,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
{isError && {error?.message} }
+ {type === "compose" && (
+
+ Whenever you make changes to domains, remember to redeploy your
+ compose to apply the changes.
+
+ )}
+