Repository: Dokploy/dokploy Branch: canary Commit: 6fb4a13a189b Files: 1217 Total size: 23.8 MB Directory structure: gitextract_so1vxlep/ ├── .devcontainer/ │ ├── Dockerfile │ └── devcontainer.json ├── .dockerignore ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature-request.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── create-pr.yml │ ├── deploy.yml │ ├── dokploy.yml │ ├── format.yml │ ├── monitoring.yml │ ├── pr-quality.yml │ ├── pull-request.yml │ └── sync-openapi-docs.yml ├── .gitignore ├── .nvmrc ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.cloud ├── Dockerfile.monitoring ├── Dockerfile.schedule ├── Dockerfile.server ├── GUIDES.md ├── LICENSE.MD ├── LICENSE_PROPRIETARY.md ├── README.md ├── SECURITY.md ├── TERMS_AND_CONDITIONS.md ├── apps/ │ ├── api/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── logger.ts │ │ │ ├── schema.ts │ │ │ ├── service.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── dokploy/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── __test__/ │ │ │ ├── cluster/ │ │ │ │ └── upload.test.ts │ │ │ ├── compose/ │ │ │ │ ├── compose.test.ts │ │ │ │ ├── config/ │ │ │ │ │ ├── config-root.test.ts │ │ │ │ │ ├── config-service.test.ts │ │ │ │ │ └── config.test.ts │ │ │ │ ├── domain/ │ │ │ │ │ ├── host-rule-format.test.ts │ │ │ │ │ ├── labels.test.ts │ │ │ │ │ ├── network-root.test.ts │ │ │ │ │ └── network-service.test.ts │ │ │ │ ├── network/ │ │ │ │ │ ├── network-root.test.ts │ │ │ │ │ ├── network-service.test.ts │ │ │ │ │ └── network.test.ts │ │ │ │ ├── secrets/ │ │ │ │ │ ├── secret-root.test.ts │ │ │ │ │ ├── secret-services.test.ts │ │ │ │ │ └── secret.test.ts │ │ │ │ ├── service/ │ │ │ │ │ ├── service-container-name.test.ts │ │ │ │ │ ├── service-depends-on.test.ts │ │ │ │ │ ├── service-extends.test.ts │ │ │ │ │ ├── service-links.test.ts │ │ │ │ │ ├── service-names.test.ts │ │ │ │ │ ├── service.test.ts │ │ │ │ │ └── sevice-volumes-from.test.ts │ │ │ │ └── volume/ │ │ │ │ ├── volume-2.test.ts │ │ │ │ ├── volume-root.test.ts │ │ │ │ ├── volume-services.test.ts │ │ │ │ └── volume.test.ts │ │ │ ├── deploy/ │ │ │ │ ├── application.command.test.ts │ │ │ │ ├── application.real.test.ts │ │ │ │ ├── github.test.ts │ │ │ │ └── soft-serve.test.ts │ │ │ ├── drop/ │ │ │ │ ├── drop.test.ts │ │ │ │ └── zips/ │ │ │ │ ├── folder1/ │ │ │ │ │ └── folder1.txt │ │ │ │ ├── folder2/ │ │ │ │ │ └── folder2.txt │ │ │ │ ├── folder3/ │ │ │ │ │ └── file3.txt │ │ │ │ └── test.txt │ │ │ ├── env/ │ │ │ │ ├── environment-access-fallback.test.ts │ │ │ │ ├── environment.test.ts │ │ │ │ ├── shared.test.ts │ │ │ │ └── stack-environment.test.ts │ │ │ ├── permissions/ │ │ │ │ ├── check-permission.test.ts │ │ │ │ ├── enterprise-only-resources.test.ts │ │ │ │ ├── resolve-permissions.test.ts │ │ │ │ └── service-access.test.ts │ │ │ ├── requests/ │ │ │ │ └── request.test.ts │ │ │ ├── server/ │ │ │ │ └── mechanizeDockerContainer.test.ts │ │ │ ├── setup.ts │ │ │ ├── templates/ │ │ │ │ ├── config.template.test.ts │ │ │ │ └── helpers.template.test.ts │ │ │ ├── traefik/ │ │ │ │ ├── server/ │ │ │ │ │ └── update-server-config.test.ts │ │ │ │ └── traefik.test.ts │ │ │ ├── utils/ │ │ │ │ └── backups.test.ts │ │ │ ├── vitest.config.ts │ │ │ └── wss/ │ │ │ ├── readValidDirectory.test.ts │ │ │ └── utils.test.ts │ │ ├── components/ │ │ │ ├── dashboard/ │ │ │ │ ├── application/ │ │ │ │ │ ├── advanced/ │ │ │ │ │ │ ├── cluster/ │ │ │ │ │ │ │ ├── modify-swarm-settings.tsx │ │ │ │ │ │ │ ├── show-cluster-settings.tsx │ │ │ │ │ │ │ └── swarm-forms/ │ │ │ │ │ │ │ ├── endpoint-spec-form.tsx │ │ │ │ │ │ │ ├── health-check-form.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── labels-form.tsx │ │ │ │ │ │ │ ├── mode-form.tsx │ │ │ │ │ │ │ ├── network-form.tsx │ │ │ │ │ │ │ ├── placement-form.tsx │ │ │ │ │ │ │ ├── restart-policy-form.tsx │ │ │ │ │ │ │ ├── rollback-config-form.tsx │ │ │ │ │ │ │ ├── stop-grace-period-form.tsx │ │ │ │ │ │ │ ├── update-config-form.tsx │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── general/ │ │ │ │ │ │ │ └── add-command.tsx │ │ │ │ │ │ ├── import/ │ │ │ │ │ │ │ └── show-import.tsx │ │ │ │ │ │ ├── ports/ │ │ │ │ │ │ │ ├── handle-ports.tsx │ │ │ │ │ │ │ └── show-port.tsx │ │ │ │ │ │ ├── redirects/ │ │ │ │ │ │ │ ├── handle-redirect.tsx │ │ │ │ │ │ │ └── show-redirects.tsx │ │ │ │ │ │ ├── security/ │ │ │ │ │ │ │ ├── handle-security.tsx │ │ │ │ │ │ │ └── show-security.tsx │ │ │ │ │ │ ├── show-build-server.tsx │ │ │ │ │ │ ├── show-resources.tsx │ │ │ │ │ │ ├── traefik/ │ │ │ │ │ │ │ ├── show-traefik-config.tsx │ │ │ │ │ │ │ └── update-traefik-config.tsx │ │ │ │ │ │ └── volumes/ │ │ │ │ │ │ ├── add-volumes.tsx │ │ │ │ │ │ ├── show-volumes.tsx │ │ │ │ │ │ └── update-volume.tsx │ │ │ │ │ ├── build/ │ │ │ │ │ │ └── show.tsx │ │ │ │ │ ├── deployments/ │ │ │ │ │ │ ├── cancel-queues.tsx │ │ │ │ │ │ ├── clear-deployments.tsx │ │ │ │ │ │ ├── kill-build.tsx │ │ │ │ │ │ ├── refresh-token.tsx │ │ │ │ │ │ ├── show-deployment.tsx │ │ │ │ │ │ ├── show-deployments-modal.tsx │ │ │ │ │ │ └── show-deployments.tsx │ │ │ │ │ ├── domains/ │ │ │ │ │ │ ├── dns-helper-modal.tsx │ │ │ │ │ │ ├── handle-domain.tsx │ │ │ │ │ │ └── show-domains.tsx │ │ │ │ │ ├── environment/ │ │ │ │ │ │ ├── show-enviroment.tsx │ │ │ │ │ │ └── show.tsx │ │ │ │ │ ├── general/ │ │ │ │ │ │ ├── generic/ │ │ │ │ │ │ │ ├── save-bitbucket-provider.tsx │ │ │ │ │ │ │ ├── save-docker-provider.tsx │ │ │ │ │ │ │ ├── save-drag-n-drop.tsx │ │ │ │ │ │ │ ├── save-git-provider.tsx │ │ │ │ │ │ │ ├── save-gitea-provider.tsx │ │ │ │ │ │ │ ├── save-github-provider.tsx │ │ │ │ │ │ │ ├── save-gitlab-provider.tsx │ │ │ │ │ │ │ ├── show.tsx │ │ │ │ │ │ │ └── unauthorized-git-provider.tsx │ │ │ │ │ │ └── show.tsx │ │ │ │ │ ├── logs/ │ │ │ │ │ │ └── show.tsx │ │ │ │ │ ├── patches/ │ │ │ │ │ │ ├── create-file-dialog.tsx │ │ │ │ │ │ ├── edit-patch-dialog.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── patch-editor.tsx │ │ │ │ │ │ └── show-patches.tsx │ │ │ │ │ ├── preview-deployments/ │ │ │ │ │ │ ├── add-preview-domain.tsx │ │ │ │ │ │ ├── show-preview-deployments.tsx │ │ │ │ │ │ └── show-preview-settings.tsx │ │ │ │ │ ├── rollbacks/ │ │ │ │ │ │ ├── Backup │ │ │ │ │ │ └── show-rollback-settings.tsx │ │ │ │ │ ├── schedules/ │ │ │ │ │ │ ├── handle-schedules.tsx │ │ │ │ │ │ ├── show-schedules.tsx │ │ │ │ │ │ └── timezones.ts │ │ │ │ │ ├── update-application.tsx │ │ │ │ │ └── volume-backups/ │ │ │ │ │ ├── handle-volume-backups.tsx │ │ │ │ │ ├── restore-volume-backups.tsx │ │ │ │ │ └── show-volume-backups.tsx │ │ │ │ ├── compose/ │ │ │ │ │ ├── advanced/ │ │ │ │ │ │ ├── add-command.tsx │ │ │ │ │ │ └── add-isolation.tsx │ │ │ │ │ ├── delete-service.tsx │ │ │ │ │ ├── general/ │ │ │ │ │ │ ├── actions.tsx │ │ │ │ │ │ ├── compose-file-editor.tsx │ │ │ │ │ │ ├── generic/ │ │ │ │ │ │ │ ├── save-bitbucket-provider-compose.tsx │ │ │ │ │ │ │ ├── save-git-provider-compose.tsx │ │ │ │ │ │ │ ├── save-gitea-provider-compose.tsx │ │ │ │ │ │ │ ├── save-github-provider-compose.tsx │ │ │ │ │ │ │ ├── save-gitlab-provider-compose.tsx │ │ │ │ │ │ │ └── show.tsx │ │ │ │ │ │ ├── randomize-compose.tsx │ │ │ │ │ │ ├── show-converted-compose.tsx │ │ │ │ │ │ └── show.tsx │ │ │ │ │ ├── logs/ │ │ │ │ │ │ ├── show-stack.tsx │ │ │ │ │ │ └── show.tsx │ │ │ │ │ └── update-compose.tsx │ │ │ │ ├── database/ │ │ │ │ │ └── backups/ │ │ │ │ │ ├── handle-backup.tsx │ │ │ │ │ ├── restore-backup.tsx │ │ │ │ │ └── show-backups.tsx │ │ │ │ ├── deployments/ │ │ │ │ │ ├── show-deployments-table.tsx │ │ │ │ │ └── show-queue-table.tsx │ │ │ │ ├── docker/ │ │ │ │ │ ├── config/ │ │ │ │ │ │ └── show-container-config.tsx │ │ │ │ │ ├── logs/ │ │ │ │ │ │ ├── docker-logs-id.tsx │ │ │ │ │ │ ├── line-count-filter.tsx │ │ │ │ │ │ ├── show-docker-modal-logs.tsx │ │ │ │ │ │ ├── show-docker-modal-stack-logs.tsx │ │ │ │ │ │ ├── since-logs-filter.tsx │ │ │ │ │ │ ├── status-logs-filter.tsx │ │ │ │ │ │ ├── terminal-line.tsx │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── show/ │ │ │ │ │ │ ├── colums.tsx │ │ │ │ │ │ └── show-containers.tsx │ │ │ │ │ └── terminal/ │ │ │ │ │ ├── docker-terminal-modal.tsx │ │ │ │ │ └── docker-terminal.tsx │ │ │ │ ├── file-system/ │ │ │ │ │ ├── show-traefik-file.tsx │ │ │ │ │ └── show-traefik-system.tsx │ │ │ │ ├── impersonation/ │ │ │ │ │ └── impersonation-bar.tsx │ │ │ │ ├── mariadb/ │ │ │ │ │ ├── general/ │ │ │ │ │ │ ├── show-external-mariadb-credentials.tsx │ │ │ │ │ │ ├── show-general-mariadb.tsx │ │ │ │ │ │ └── show-internal-mariadb-credentials.tsx │ │ │ │ │ └── update-mariadb.tsx │ │ │ │ ├── mongo/ │ │ │ │ │ ├── general/ │ │ │ │ │ │ ├── show-external-mongo-credentials.tsx │ │ │ │ │ │ ├── show-general-mongo.tsx │ │ │ │ │ │ └── show-internal-mongo-credentials.tsx │ │ │ │ │ └── update-mongo.tsx │ │ │ │ ├── monitoring/ │ │ │ │ │ ├── free/ │ │ │ │ │ │ └── container/ │ │ │ │ │ │ ├── docker-block-chart.tsx │ │ │ │ │ │ ├── docker-cpu-chart.tsx │ │ │ │ │ │ ├── docker-disk-chart.tsx │ │ │ │ │ │ ├── docker-memory-chart.tsx │ │ │ │ │ │ ├── docker-network-chart.tsx │ │ │ │ │ │ ├── show-free-compose-monitoring.tsx │ │ │ │ │ │ └── show-free-container-monitoring.tsx │ │ │ │ │ └── paid/ │ │ │ │ │ ├── container/ │ │ │ │ │ │ ├── container-block-chart.tsx │ │ │ │ │ │ ├── container-cpu-chart.tsx │ │ │ │ │ │ ├── container-memory-chart.tsx │ │ │ │ │ │ ├── container-network-chart.tsx │ │ │ │ │ │ ├── show-paid-compose-monitoring.tsx │ │ │ │ │ │ └── show-paid-container-monitoring.tsx │ │ │ │ │ └── servers/ │ │ │ │ │ ├── cpu-chart.tsx │ │ │ │ │ ├── disk-chart.tsx │ │ │ │ │ ├── memory-chart.tsx │ │ │ │ │ ├── network-chart.tsx │ │ │ │ │ └── show-paid-monitoring.tsx │ │ │ │ ├── mysql/ │ │ │ │ │ ├── general/ │ │ │ │ │ │ ├── show-external-mysql-credentials.tsx │ │ │ │ │ │ ├── show-general-mysql.tsx │ │ │ │ │ │ └── show-internal-mysql-credentials.tsx │ │ │ │ │ └── update-mysql.tsx │ │ │ │ ├── organization/ │ │ │ │ │ └── handle-organization.tsx │ │ │ │ ├── postgres/ │ │ │ │ │ ├── advanced/ │ │ │ │ │ │ └── show-custom-command.tsx │ │ │ │ │ ├── general/ │ │ │ │ │ │ ├── show-external-postgres-credentials.tsx │ │ │ │ │ │ ├── show-general-postgres.tsx │ │ │ │ │ │ └── show-internal-postgres-credentials.tsx │ │ │ │ │ └── update-postgres.tsx │ │ │ │ ├── project/ │ │ │ │ │ ├── add-ai-assistant.tsx │ │ │ │ │ ├── add-application.tsx │ │ │ │ │ ├── add-compose.tsx │ │ │ │ │ ├── add-database.tsx │ │ │ │ │ ├── add-template.tsx │ │ │ │ │ ├── advanced-environment-selector.tsx │ │ │ │ │ ├── ai/ │ │ │ │ │ │ ├── step-one.tsx │ │ │ │ │ │ ├── step-three.tsx │ │ │ │ │ │ ├── step-two.tsx │ │ │ │ │ │ └── template-generator.tsx │ │ │ │ │ ├── duplicate-project.tsx │ │ │ │ │ └── environment-variables.tsx │ │ │ │ ├── projects/ │ │ │ │ │ ├── handle-project.tsx │ │ │ │ │ ├── project-environment.tsx │ │ │ │ │ └── show.tsx │ │ │ │ ├── redis/ │ │ │ │ │ ├── general/ │ │ │ │ │ │ ├── show-external-redis-credentials.tsx │ │ │ │ │ │ ├── show-general-redis.tsx │ │ │ │ │ │ └── show-internal-redis-credentials.tsx │ │ │ │ │ └── update-redis.tsx │ │ │ │ ├── requests/ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── request-distribution-chart.tsx │ │ │ │ │ ├── requests-table.tsx │ │ │ │ │ ├── show-requests.tsx │ │ │ │ │ └── status-request-filter.tsx │ │ │ │ ├── search-command.tsx │ │ │ │ ├── settings/ │ │ │ │ │ ├── ai-form.tsx │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── add-api-key.tsx │ │ │ │ │ │ └── show-api-keys.tsx │ │ │ │ │ ├── billing/ │ │ │ │ │ │ ├── show-billing-invoices.tsx │ │ │ │ │ │ ├── show-billing.tsx │ │ │ │ │ │ ├── show-invoices.tsx │ │ │ │ │ │ └── show-welcome-dokploy.tsx │ │ │ │ │ ├── certificates/ │ │ │ │ │ │ ├── add-certificate.tsx │ │ │ │ │ │ ├── show-certificates.tsx │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── cluster/ │ │ │ │ │ │ ├── nodes/ │ │ │ │ │ │ │ ├── add-node.tsx │ │ │ │ │ │ │ ├── manager/ │ │ │ │ │ │ │ │ └── add-manager.tsx │ │ │ │ │ │ │ ├── show-node-data.tsx │ │ │ │ │ │ │ ├── show-nodes-modal.tsx │ │ │ │ │ │ │ ├── show-nodes.tsx │ │ │ │ │ │ │ └── workers/ │ │ │ │ │ │ │ └── add-worker.tsx │ │ │ │ │ │ └── registry/ │ │ │ │ │ │ ├── handle-registry.tsx │ │ │ │ │ │ └── show-registry.tsx │ │ │ │ │ ├── destination/ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── handle-destinations.tsx │ │ │ │ │ │ └── show-destinations.tsx │ │ │ │ │ ├── git/ │ │ │ │ │ │ ├── bitbucket/ │ │ │ │ │ │ │ ├── add-bitbucket-provider.tsx │ │ │ │ │ │ │ └── edit-bitbucket-provider.tsx │ │ │ │ │ │ ├── gitea/ │ │ │ │ │ │ │ ├── add-gitea-provider.tsx │ │ │ │ │ │ │ └── edit-gitea-provider.tsx │ │ │ │ │ │ ├── github/ │ │ │ │ │ │ │ ├── add-github-provider.tsx │ │ │ │ │ │ │ └── edit-github-provider.tsx │ │ │ │ │ │ ├── gitlab/ │ │ │ │ │ │ │ ├── add-gitlab-provider.tsx │ │ │ │ │ │ │ └── edit-gitlab-provider.tsx │ │ │ │ │ │ └── show-git-providers.tsx │ │ │ │ │ ├── handle-ai.tsx │ │ │ │ │ ├── linking-account/ │ │ │ │ │ │ └── linking-account.tsx │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ ├── handle-notifications.tsx │ │ │ │ │ │ └── show-notifications.tsx │ │ │ │ │ ├── profile/ │ │ │ │ │ │ ├── configure-2fa.tsx │ │ │ │ │ │ ├── enable-2fa.tsx │ │ │ │ │ │ └── profile-form.tsx │ │ │ │ │ ├── servers/ │ │ │ │ │ │ ├── actions/ │ │ │ │ │ │ │ ├── show-dokploy-actions.tsx │ │ │ │ │ │ │ ├── show-server-actions.tsx │ │ │ │ │ │ │ ├── show-storage-actions.tsx │ │ │ │ │ │ │ ├── show-traefik-actions.tsx │ │ │ │ │ │ │ └── toggle-docker-cleanup.tsx │ │ │ │ │ │ ├── edit-script.tsx │ │ │ │ │ │ ├── gpu-support-modal.tsx │ │ │ │ │ │ ├── gpu-support.tsx │ │ │ │ │ │ ├── handle-servers.tsx │ │ │ │ │ │ ├── security-audit.tsx │ │ │ │ │ │ ├── setup-monitoring.tsx │ │ │ │ │ │ ├── setup-server.tsx │ │ │ │ │ │ ├── show-docker-containers-modal.tsx │ │ │ │ │ │ ├── show-monitoring-modal.tsx │ │ │ │ │ │ ├── show-schedules-modal.tsx │ │ │ │ │ │ ├── show-servers.tsx │ │ │ │ │ │ ├── show-swarm-overview-modal.tsx │ │ │ │ │ │ ├── show-traefik-file-system-modal.tsx │ │ │ │ │ │ ├── validate-server.tsx │ │ │ │ │ │ └── welcome-stripe/ │ │ │ │ │ │ ├── create-server.tsx │ │ │ │ │ │ ├── create-ssh-key.tsx │ │ │ │ │ │ ├── setup.tsx │ │ │ │ │ │ ├── verify.tsx │ │ │ │ │ │ └── welcome-suscription.tsx │ │ │ │ │ ├── ssh-keys/ │ │ │ │ │ │ ├── handle-ssh-keys.tsx │ │ │ │ │ │ └── show-ssh-keys.tsx │ │ │ │ │ ├── tags/ │ │ │ │ │ │ ├── handle-tag.tsx │ │ │ │ │ │ └── tag-manager.tsx │ │ │ │ │ ├── users/ │ │ │ │ │ │ ├── add-invitation.tsx │ │ │ │ │ │ ├── add-permissions.tsx │ │ │ │ │ │ ├── change-role.tsx │ │ │ │ │ │ ├── show-invitations.tsx │ │ │ │ │ │ └── show-users.tsx │ │ │ │ │ ├── web-domain.tsx │ │ │ │ │ ├── web-server/ │ │ │ │ │ │ ├── docker-terminal-modal.tsx │ │ │ │ │ │ ├── edit-traefik-env.tsx │ │ │ │ │ │ ├── local-server-config.tsx │ │ │ │ │ │ ├── manage-traefik-ports.tsx │ │ │ │ │ │ ├── show-modal-logs.tsx │ │ │ │ │ │ ├── terminal-modal.tsx │ │ │ │ │ │ ├── terminal.tsx │ │ │ │ │ │ ├── toggle-auto-check-updates.tsx │ │ │ │ │ │ ├── update-server-ip.tsx │ │ │ │ │ │ ├── update-server.tsx │ │ │ │ │ │ └── update-webserver.tsx │ │ │ │ │ └── web-server.tsx │ │ │ │ ├── shared/ │ │ │ │ │ ├── rebuild-database.tsx │ │ │ │ │ └── show-database-advanced-settings.tsx │ │ │ │ └── swarm/ │ │ │ │ ├── applications/ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ └── show-applications.tsx │ │ │ │ ├── details/ │ │ │ │ │ ├── details-card.tsx │ │ │ │ │ └── show-node-config.tsx │ │ │ │ └── monitoring-card.tsx │ │ │ ├── icons/ │ │ │ │ ├── data-tools-icons.tsx │ │ │ │ └── notification-icons.tsx │ │ │ ├── layouts/ │ │ │ │ ├── dashboard-layout.tsx │ │ │ │ ├── onboarding-layout.tsx │ │ │ │ ├── side.tsx │ │ │ │ ├── update-server.tsx │ │ │ │ └── user-nav.tsx │ │ │ ├── proprietary/ │ │ │ │ ├── audit-logs/ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ └── show-audit-logs.tsx │ │ │ │ ├── auth/ │ │ │ │ │ ├── sign-in-with-github.tsx │ │ │ │ │ └── sign-in-with-google.tsx │ │ │ │ ├── enterprise-feature-gate.tsx │ │ │ │ ├── license-keys/ │ │ │ │ │ └── license-key.tsx │ │ │ │ ├── roles/ │ │ │ │ │ └── manage-custom-roles.tsx │ │ │ │ ├── sso/ │ │ │ │ │ ├── register-oidc-dialog.tsx │ │ │ │ │ ├── register-saml-dialog.tsx │ │ │ │ │ ├── sign-in-with-sso.tsx │ │ │ │ │ └── sso-settings.tsx │ │ │ │ └── whitelabeling/ │ │ │ │ ├── whitelabeling-preview.tsx │ │ │ │ ├── whitelabeling-provider.tsx │ │ │ │ └── whitelabeling-settings.tsx │ │ │ ├── shared/ │ │ │ │ ├── ChatwootWidget.tsx │ │ │ │ ├── HubSpotWidget.tsx │ │ │ │ ├── advance-breadcrumb.tsx │ │ │ │ ├── alert-block.tsx │ │ │ │ ├── breadcrumb-sidebar.tsx │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── compose-spec.json │ │ │ │ ├── date-tooltip.tsx │ │ │ │ ├── dialog-action.tsx │ │ │ │ ├── drawer-logs.tsx │ │ │ │ ├── focus-shortcut-input.tsx │ │ │ │ ├── logo.tsx │ │ │ │ ├── status-tooltip.tsx │ │ │ │ ├── tag-badge.tsx │ │ │ │ ├── tag-filter.tsx │ │ │ │ ├── tag-selector.tsx │ │ │ │ └── toggle-visibility-input.tsx │ │ │ └── ui/ │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── dropzone.tsx │ │ │ ├── file-tree.tsx │ │ │ ├── form.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── modeToggle.tsx │ │ │ ├── number-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── secrets.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── sidebar.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── time-badge.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ ├── components.json │ │ ├── docker/ │ │ │ ├── build.sh │ │ │ ├── feat.sh │ │ │ └── push.sh │ │ ├── drizzle/ │ │ │ ├── 0000_reflective_puck.sql │ │ │ ├── 0001_striped_tattoo.sql │ │ │ ├── 0002_ambiguous_carlie_cooper.sql │ │ │ ├── 0003_square_lightspeed.sql │ │ │ ├── 0004_nice_tenebrous.sql │ │ │ ├── 0005_cute_terror.sql │ │ │ ├── 0006_oval_jimmy_woo.sql │ │ │ ├── 0007_cute_guardsmen.sql │ │ │ ├── 0008_lazy_sage.sql │ │ │ ├── 0009_majestic_spencer_smythe.sql │ │ │ ├── 0010_lean_black_widow.sql │ │ │ ├── 0011_petite_calypso.sql │ │ │ ├── 0012_chubby_umar.sql │ │ │ ├── 0013_blushing_starjammers.sql │ │ │ ├── 0014_same_hammerhead.sql │ │ │ ├── 0015_fearless_callisto.sql │ │ │ ├── 0016_chunky_leopardon.sql │ │ │ ├── 0017_minor_post.sql │ │ │ ├── 0018_careful_killmonger.sql │ │ │ ├── 0019_heavy_freak.sql │ │ │ ├── 0020_fantastic_slapstick.sql │ │ │ ├── 0021_premium_sebastian_shaw.sql │ │ │ ├── 0022_warm_colonel_america.sql │ │ │ ├── 0023_icy_maverick.sql │ │ │ ├── 0024_dapper_supernaut.sql │ │ │ ├── 0025_lying_mephisto.sql │ │ │ ├── 0026_known_dormammu.sql │ │ │ ├── 0027_red_lady_bullseye.sql │ │ │ ├── 0028_jittery_eternity.sql │ │ │ ├── 0029_colossal_zodiak.sql │ │ │ ├── 0030_little_kabuki.sql │ │ │ ├── 0031_steep_vulture.sql │ │ │ ├── 0032_flashy_shadow_king.sql │ │ │ ├── 0033_white_hawkeye.sql │ │ │ ├── 0034_aspiring_secret_warriors.sql │ │ │ ├── 0035_cool_gravity.sql │ │ │ ├── 0036_tired_ronan.sql │ │ │ ├── 0037_legal_namor.sql │ │ │ ├── 0038_rapid_landau.sql │ │ │ ├── 0039_many_tiger_shark.sql │ │ │ ├── 0040_graceful_wolfsbane.sql │ │ │ ├── 0041_huge_bruce_banner.sql │ │ │ ├── 0042_fancy_havok.sql │ │ │ ├── 0043_closed_naoko.sql │ │ │ ├── 0044_sour_true_believers.sql │ │ │ ├── 0045_smiling_blur.sql │ │ │ ├── 0046_purple_sleeper.sql │ │ │ ├── 0047_tidy_revanche.sql │ │ │ ├── 0048_flat_expediter.sql │ │ │ ├── 0049_dark_leopardon.sql │ │ │ ├── 0050_nappy_wrecker.sql │ │ │ ├── 0051_hard_gorgon.sql │ │ │ ├── 0052_bumpy_luckman.sql │ │ │ ├── 0053_broken_kulan_gath.sql │ │ │ ├── 0054_nervous_spencer_smythe.sql │ │ │ ├── 0055_next_serpent_society.sql │ │ │ ├── 0056_majestic_skaar.sql │ │ │ ├── 0057_tricky_living_tribunal.sql │ │ │ ├── 0058_brown_sharon_carter.sql │ │ │ ├── 0059_striped_bill_hollister.sql │ │ │ ├── 0060_disable-aggressive-cache.sql │ │ │ ├── 0061_many_molten_man.sql │ │ │ ├── 0062_slippery_white_tiger.sql │ │ │ ├── 0063_panoramic_dreadnoughts.sql │ │ │ ├── 0064_previous_agent_brand.sql │ │ │ ├── 0065_daily_zaladane.sql │ │ │ ├── 0066_yielding_echo.sql │ │ │ ├── 0067_condemned_sugar_man.sql │ │ │ ├── 0068_complex_rhino.sql │ │ │ ├── 0069_legal_bill_hollister.sql │ │ │ ├── 0070_useful_serpent_society.sql │ │ │ ├── 0071_flaky_black_queen.sql │ │ │ ├── 0072_green_susan_delgado.sql │ │ │ ├── 0073_hot_domino.sql │ │ │ ├── 0074_black_quasar.sql │ │ │ ├── 0075_young_typhoid_mary.sql │ │ │ ├── 0076_young_sharon_ventura.sql │ │ │ ├── 0077_chemical_dreadnoughts.sql │ │ │ ├── 0078_uneven_omega_sentinel.sql │ │ │ ├── 0079_bizarre_wendell_rand.sql │ │ │ ├── 0080_sleepy_sinister_six.sql │ │ │ ├── 0081_lovely_mentallo.sql │ │ │ ├── 0082_clean_mandarin.sql │ │ │ ├── 0083_parallel_stranger.sql │ │ │ ├── 0084_thin_iron_lad.sql │ │ │ ├── 0085_equal_captain_stacy.sql │ │ │ ├── 0086_rainy_gertrude_yorkes.sql │ │ │ ├── 0087_lively_risque.sql │ │ │ ├── 0088_illegal_ma_gnuci.sql │ │ │ ├── 0089_noisy_sandman.sql │ │ │ ├── 0090_clean_wolf_cub.sql │ │ │ ├── 0091_spotty_kulan_gath.sql │ │ │ ├── 0092_stiff_the_watchers.sql │ │ │ ├── 0093_nice_gorilla_man.sql │ │ │ ├── 0094_numerous_carmella_unuscione.sql │ │ │ ├── 0095_curly_justice.sql │ │ │ ├── 0096_small_shaman.sql │ │ │ ├── 0097_hard_lizard.sql │ │ │ ├── 0098_conscious_chat.sql │ │ │ ├── 0099_wise_golden_guardian.sql │ │ │ ├── 0100_purple_rogue.sql │ │ │ ├── 0101_moaning_blazing_skull.sql │ │ │ ├── 0102_opposite_grandmaster.sql │ │ │ ├── 0103_cultured_pestilence.sql │ │ │ ├── 0104_omniscient_randall.sql │ │ │ ├── 0105_clumsy_quicksilver.sql │ │ │ ├── 0106_purple_maggott.sql │ │ │ ├── 0107_loud_kang.sql │ │ │ ├── 0108_lazy_next_avengers.sql │ │ │ ├── 0109_remarkable_sauron.sql │ │ │ ├── 0110_red_psynapse.sql │ │ │ ├── 0111_mushy_wolfsbane.sql │ │ │ ├── 0112_freezing_skrulls.sql │ │ │ ├── 0113_complete_rafael_vega.sql │ │ │ ├── 0114_dry_black_tom.sql │ │ │ ├── 0115_serious_black_bird.sql │ │ │ ├── 0116_amusing_firedrake.sql │ │ │ ├── 0117_lumpy_nuke.sql │ │ │ ├── 0118_loose_anita_blake.sql │ │ │ ├── 0119_bouncy_morbius.sql │ │ │ ├── 0120_lame_captain_midlands.sql │ │ │ ├── 0121_rainy_cargill.sql │ │ │ ├── 0122_absent_frightful_four.sql │ │ │ ├── 0123_cloudy_piledriver.sql │ │ │ ├── 0124_certain_cloak.sql │ │ │ ├── 0125_neat_the_phantom.sql │ │ │ ├── 0126_nifty_monster_badoon.sql │ │ │ ├── 0127_superb_alice.sql │ │ │ ├── 0128_hard_falcon.sql │ │ │ ├── 0129_pale_roughhouse.sql │ │ │ ├── 0130_perpetual_screwball.sql │ │ │ ├── 0131_volatile_beast.sql │ │ │ ├── 0132_clean_layla_miller.sql │ │ │ ├── 0133_striped_the_order.sql │ │ │ ├── 0134_strong_hercules.sql │ │ │ ├── 0135_illegal_magik.sql │ │ │ ├── 0136_tidy_puff_adder.sql │ │ │ ├── 0137_colossal_sally_floyd.sql │ │ │ ├── 0138_pretty_ironclad.sql │ │ │ ├── 0139_brave_bloodstorm.sql │ │ │ ├── 0140_lame_mattie_franklin.sql │ │ │ ├── 0141_plain_earthquake.sql │ │ │ ├── 0142_outstanding_tusk.sql │ │ │ ├── 0143_brown_ultron.sql │ │ │ ├── 0144_odd_gunslinger.sql │ │ │ ├── 0145_remarkable_titania.sql │ │ │ ├── 0146_bumpy_morg.sql │ │ │ ├── 0147_right_lake.sql │ │ │ ├── 0148_futuristic_bullseye.sql │ │ │ ├── 0149_rare_radioactive_man.sql │ │ │ ├── 0150_nappy_blue_blade.sql │ │ │ ├── 0151_modern_sunfire.sql │ │ │ ├── 0152_odd_firelord.sql │ │ │ └── meta/ │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ ├── 0006_snapshot.json │ │ │ ├── 0007_snapshot.json │ │ │ ├── 0008_snapshot.json │ │ │ ├── 0009_snapshot.json │ │ │ ├── 0010_snapshot.json │ │ │ ├── 0011_snapshot.json │ │ │ ├── 0012_snapshot.json │ │ │ ├── 0013_snapshot.json │ │ │ ├── 0014_snapshot.json │ │ │ ├── 0015_snapshot.json │ │ │ ├── 0016_snapshot.json │ │ │ ├── 0017_snapshot.json │ │ │ ├── 0018_snapshot.json │ │ │ ├── 0019_snapshot.json │ │ │ ├── 0020_snapshot.json │ │ │ ├── 0021_snapshot.json │ │ │ ├── 0022_snapshot.json │ │ │ ├── 0023_snapshot.json │ │ │ ├── 0024_snapshot.json │ │ │ ├── 0025_snapshot.json │ │ │ ├── 0026_snapshot.json │ │ │ ├── 0027_snapshot.json │ │ │ ├── 0028_snapshot.json │ │ │ ├── 0029_snapshot.json │ │ │ ├── 0030_snapshot.json │ │ │ ├── 0031_snapshot.json │ │ │ ├── 0032_snapshot.json │ │ │ ├── 0033_snapshot.json │ │ │ ├── 0034_snapshot.json │ │ │ ├── 0035_snapshot.json │ │ │ ├── 0036_snapshot.json │ │ │ ├── 0037_snapshot.json │ │ │ ├── 0038_snapshot.json │ │ │ ├── 0039_snapshot.json │ │ │ ├── 0040_snapshot.json │ │ │ ├── 0041_snapshot.json │ │ │ ├── 0042_snapshot.json │ │ │ ├── 0043_snapshot.json │ │ │ ├── 0044_snapshot.json │ │ │ ├── 0045_snapshot.json │ │ │ ├── 0046_snapshot.json │ │ │ ├── 0047_snapshot.json │ │ │ ├── 0048_snapshot.json │ │ │ ├── 0049_snapshot.json │ │ │ ├── 0050_snapshot.json │ │ │ ├── 0051_snapshot.json │ │ │ ├── 0052_snapshot.json │ │ │ ├── 0053_snapshot.json │ │ │ ├── 0054_snapshot.json │ │ │ ├── 0055_snapshot.json │ │ │ ├── 0056_snapshot.json │ │ │ ├── 0057_snapshot.json │ │ │ ├── 0058_snapshot.json │ │ │ ├── 0059_snapshot.json │ │ │ ├── 0060_snapshot.json │ │ │ ├── 0061_snapshot.json │ │ │ ├── 0062_snapshot.json │ │ │ ├── 0063_snapshot.json │ │ │ ├── 0064_snapshot.json │ │ │ ├── 0065_snapshot.json │ │ │ ├── 0066_snapshot.json │ │ │ ├── 0067_snapshot.json │ │ │ ├── 0068_snapshot.json │ │ │ ├── 0069_snapshot.json │ │ │ ├── 0070_snapshot.json │ │ │ ├── 0071_snapshot.json │ │ │ ├── 0072_snapshot.json │ │ │ ├── 0073_snapshot.json │ │ │ ├── 0074_snapshot.json │ │ │ ├── 0075_snapshot.json │ │ │ ├── 0076_snapshot.json │ │ │ ├── 0077_snapshot.json │ │ │ ├── 0078_snapshot.json │ │ │ ├── 0079_snapshot.json │ │ │ ├── 0080_snapshot.json │ │ │ ├── 0081_snapshot.json │ │ │ ├── 0082_snapshot.json │ │ │ ├── 0083_snapshot.json │ │ │ ├── 0084_snapshot.json │ │ │ ├── 0085_snapshot.json │ │ │ ├── 0086_snapshot.json │ │ │ ├── 0087_snapshot.json │ │ │ ├── 0088_snapshot.json │ │ │ ├── 0089_snapshot.json │ │ │ ├── 0090_snapshot.json │ │ │ ├── 0091_snapshot.json │ │ │ ├── 0092_snapshot.json │ │ │ ├── 0093_snapshot.json │ │ │ ├── 0094_snapshot.json │ │ │ ├── 0095_snapshot.json │ │ │ ├── 0096_snapshot.json │ │ │ ├── 0097_snapshot.json │ │ │ ├── 0098_snapshot.json │ │ │ ├── 0099_snapshot.json │ │ │ ├── 0100_snapshot.json │ │ │ ├── 0101_snapshot.json │ │ │ ├── 0102_snapshot.json │ │ │ ├── 0103_snapshot.json │ │ │ ├── 0104_snapshot.json │ │ │ ├── 0105_snapshot.json │ │ │ ├── 0106_snapshot.json │ │ │ ├── 0107_snapshot.json │ │ │ ├── 0108_snapshot.json │ │ │ ├── 0109_snapshot.json │ │ │ ├── 0110_snapshot.json │ │ │ ├── 0111_snapshot.json │ │ │ ├── 0112_snapshot.json │ │ │ ├── 0113_snapshot.json │ │ │ ├── 0114_snapshot.json │ │ │ ├── 0115_snapshot.json │ │ │ ├── 0116_snapshot.json │ │ │ ├── 0117_snapshot.json │ │ │ ├── 0118_snapshot.json │ │ │ ├── 0119_snapshot.json │ │ │ ├── 0120_snapshot.json │ │ │ ├── 0121_snapshot.json │ │ │ ├── 0122_snapshot.json │ │ │ ├── 0123_snapshot.json │ │ │ ├── 0124_snapshot.json │ │ │ ├── 0125_snapshot.json │ │ │ ├── 0126_snapshot.json │ │ │ ├── 0127_snapshot.json │ │ │ ├── 0128_snapshot.json │ │ │ ├── 0129_snapshot.json │ │ │ ├── 0130_snapshot.json │ │ │ ├── 0131_snapshot.json │ │ │ ├── 0132_snapshot.json │ │ │ ├── 0133_snapshot.json │ │ │ ├── 0134_snapshot.json │ │ │ ├── 0135_snapshot.json │ │ │ ├── 0136_snapshot.json │ │ │ ├── 0137_snapshot.json │ │ │ ├── 0138_snapshot.json │ │ │ ├── 0139_snapshot.json │ │ │ ├── 0140_snapshot.json │ │ │ ├── 0141_snapshot.json │ │ │ ├── 0142_snapshot.json │ │ │ ├── 0143_snapshot.json │ │ │ ├── 0144_snapshot.json │ │ │ ├── 0145_snapshot.json │ │ │ ├── 0146_snapshot.json │ │ │ ├── 0147_snapshot.json │ │ │ ├── 0148_snapshot.json │ │ │ ├── 0149_snapshot.json │ │ │ ├── 0150_snapshot.json │ │ │ ├── 0151_snapshot.json │ │ │ ├── 0152_snapshot.json │ │ │ ├── _journal.json │ │ │ └── _journal.json.backup │ │ ├── esbuild.config.ts │ │ ├── hooks/ │ │ │ ├── use-health-check-after-mutation.ts │ │ │ ├── use-keyboard-nav.tsx │ │ │ ├── use-mobile.tsx │ │ │ └── useLocalStorage.tsx │ │ ├── lib/ │ │ │ ├── auth-client.ts │ │ │ ├── avatar-utils.ts │ │ │ ├── password-utils.ts │ │ │ ├── slug.ts │ │ │ └── utils.ts │ │ ├── migration.ts │ │ ├── next.config.mjs │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── _app.tsx │ │ │ ├── _document.tsx │ │ │ ├── _error.tsx │ │ │ ├── accept-invitation/ │ │ │ │ └── [accept-invitation].tsx │ │ │ ├── api/ │ │ │ │ ├── [...trpc].ts │ │ │ │ ├── auth/ │ │ │ │ │ └── [...all].ts │ │ │ │ ├── deploy/ │ │ │ │ │ ├── [refreshToken].ts │ │ │ │ │ ├── compose/ │ │ │ │ │ │ └── [refreshToken].ts │ │ │ │ │ └── github.ts │ │ │ │ ├── health.ts │ │ │ │ ├── providers/ │ │ │ │ │ ├── gitea/ │ │ │ │ │ │ ├── authorize.ts │ │ │ │ │ │ ├── callback.ts │ │ │ │ │ │ └── helper.ts │ │ │ │ │ ├── github/ │ │ │ │ │ │ ├── setup.ts │ │ │ │ │ │ └── webhook.ts │ │ │ │ │ └── gitlab/ │ │ │ │ │ └── callback.ts │ │ │ │ ├── stripe/ │ │ │ │ │ └── webhook.ts │ │ │ │ └── trpc/ │ │ │ │ └── [trpc].ts │ │ │ ├── dashboard/ │ │ │ │ ├── deployments.tsx │ │ │ │ ├── docker.tsx │ │ │ │ ├── monitoring.tsx │ │ │ │ ├── project/ │ │ │ │ │ └── [projectId]/ │ │ │ │ │ └── environment/ │ │ │ │ │ ├── [environmentId]/ │ │ │ │ │ │ └── services/ │ │ │ │ │ │ ├── application/ │ │ │ │ │ │ │ └── [applicationId].tsx │ │ │ │ │ │ ├── compose/ │ │ │ │ │ │ │ └── [composeId].tsx │ │ │ │ │ │ ├── mariadb/ │ │ │ │ │ │ │ └── [mariadbId].tsx │ │ │ │ │ │ ├── mongo/ │ │ │ │ │ │ │ └── [mongoId].tsx │ │ │ │ │ │ ├── mysql/ │ │ │ │ │ │ │ └── [mysqlId].tsx │ │ │ │ │ │ ├── postgres/ │ │ │ │ │ │ │ └── [postgresId].tsx │ │ │ │ │ │ └── redis/ │ │ │ │ │ │ └── [redisId].tsx │ │ │ │ │ └── [environmentId].tsx │ │ │ │ ├── projects.tsx │ │ │ │ ├── requests.tsx │ │ │ │ ├── schedules.tsx │ │ │ │ ├── settings/ │ │ │ │ │ ├── ai.tsx │ │ │ │ │ ├── audit-logs.tsx │ │ │ │ │ ├── billing.tsx │ │ │ │ │ ├── certificates.tsx │ │ │ │ │ ├── cluster.tsx │ │ │ │ │ ├── destinations.tsx │ │ │ │ │ ├── git-providers.tsx │ │ │ │ │ ├── invoices.tsx │ │ │ │ │ ├── license.tsx │ │ │ │ │ ├── notifications.tsx │ │ │ │ │ ├── profile.tsx │ │ │ │ │ ├── registry.tsx │ │ │ │ │ ├── server.tsx │ │ │ │ │ ├── servers.tsx │ │ │ │ │ ├── ssh-keys.tsx │ │ │ │ │ ├── sso.tsx │ │ │ │ │ ├── tags.tsx │ │ │ │ │ ├── users.tsx │ │ │ │ │ └── whitelabeling.tsx │ │ │ │ ├── swarm.tsx │ │ │ │ └── traefik.tsx │ │ │ ├── index.tsx │ │ │ ├── invitation.tsx │ │ │ ├── register.tsx │ │ │ ├── reset-password.tsx │ │ │ ├── send-reset-password.tsx │ │ │ └── swagger.tsx │ │ ├── postcss.config.cjs │ │ ├── public/ │ │ │ ├── locales/ │ │ │ │ ├── az/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── de/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── en/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── es/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── fa/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── fr/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── id/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── it/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── ja/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── ko/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── kz/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── ml/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── nl/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── no/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── pl/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── pt-br/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── ru/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── tr/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── uk/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ ├── zh-Hans/ │ │ │ │ │ ├── common.json │ │ │ │ │ └── settings.json │ │ │ │ └── zh-Hant/ │ │ │ │ ├── common.json │ │ │ │ └── settings.json │ │ │ └── robots.txt │ │ ├── reset-2fa.ts │ │ ├── reset-password.ts │ │ ├── scripts/ │ │ │ └── generate-openapi.ts │ │ ├── server/ │ │ │ ├── api/ │ │ │ │ ├── root.ts │ │ │ │ ├── routers/ │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── ai.ts │ │ │ │ │ ├── application.ts │ │ │ │ │ ├── backup.ts │ │ │ │ │ ├── bitbucket.ts │ │ │ │ │ ├── certificate.ts │ │ │ │ │ ├── cluster.ts │ │ │ │ │ ├── compose.ts │ │ │ │ │ ├── deployment.ts │ │ │ │ │ ├── destination.ts │ │ │ │ │ ├── docker.ts │ │ │ │ │ ├── domain.ts │ │ │ │ │ ├── environment.ts │ │ │ │ │ ├── git-provider.ts │ │ │ │ │ ├── gitea.ts │ │ │ │ │ ├── github.ts │ │ │ │ │ ├── gitlab.ts │ │ │ │ │ ├── mariadb.ts │ │ │ │ │ ├── mongo.ts │ │ │ │ │ ├── mount.ts │ │ │ │ │ ├── mysql.ts │ │ │ │ │ ├── notification.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── patch.ts │ │ │ │ │ ├── port.ts │ │ │ │ │ ├── postgres.ts │ │ │ │ │ ├── preview-deployment.ts │ │ │ │ │ ├── project.ts │ │ │ │ │ ├── proprietary/ │ │ │ │ │ │ ├── audit-log.ts │ │ │ │ │ │ ├── custom-role.ts │ │ │ │ │ │ ├── license-key.ts │ │ │ │ │ │ ├── sso.ts │ │ │ │ │ │ └── whitelabeling.ts │ │ │ │ │ ├── redirects.ts │ │ │ │ │ ├── redis.ts │ │ │ │ │ ├── registry.ts │ │ │ │ │ ├── rollbacks.ts │ │ │ │ │ ├── schedule.ts │ │ │ │ │ ├── security.ts │ │ │ │ │ ├── server.ts │ │ │ │ │ ├── settings.ts │ │ │ │ │ ├── ssh-key.ts │ │ │ │ │ ├── stripe.ts │ │ │ │ │ ├── swarm.ts │ │ │ │ │ ├── tag.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── volume-backups.ts │ │ │ │ ├── trpc.ts │ │ │ │ └── utils/ │ │ │ │ └── audit.ts │ │ │ ├── db/ │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── index.ts │ │ │ │ ├── migration.ts │ │ │ │ ├── reset.ts │ │ │ │ ├── schema/ │ │ │ │ │ └── index.ts │ │ │ │ └── validations/ │ │ │ │ ├── domain.ts │ │ │ │ └── index.ts │ │ │ ├── queues/ │ │ │ │ ├── deployments-queue.ts │ │ │ │ ├── queue-types.ts │ │ │ │ ├── queueSetup.ts │ │ │ │ └── redis-connection.ts │ │ │ ├── server.ts │ │ │ ├── utils/ │ │ │ │ ├── backup.ts │ │ │ │ ├── deploy.ts │ │ │ │ ├── docker.ts │ │ │ │ ├── enterprise.ts │ │ │ │ └── stripe.ts │ │ │ └── wss/ │ │ │ ├── docker-container-logs.ts │ │ │ ├── docker-container-terminal.ts │ │ │ ├── docker-stats.ts │ │ │ ├── drawer-logs.ts │ │ │ ├── listen-deployment.ts │ │ │ ├── terminal.ts │ │ │ └── utils.ts │ │ ├── setup.ts │ │ ├── styles/ │ │ │ └── globals.css │ │ ├── tailwind.config.ts │ │ ├── templates/ │ │ │ ├── templates.ts │ │ │ └── utils/ │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.server.json │ │ ├── types/ │ │ │ └── chatwoot.d.ts │ │ ├── utils/ │ │ │ ├── api.ts │ │ │ ├── gitea-utils.ts │ │ │ ├── hooks/ │ │ │ │ ├── use-debounce.ts │ │ │ │ ├── use-url.ts │ │ │ │ └── use-whitelabeling.ts │ │ │ └── schema.ts │ │ └── wait-for-postgres.ts │ ├── monitoring/ │ │ ├── .gitignore │ │ ├── LICENSE.md │ │ ├── README.md │ │ ├── config/ │ │ │ └── metrics.go │ │ ├── containers/ │ │ │ ├── config.go │ │ │ ├── monitor.go │ │ │ └── types.go │ │ ├── database/ │ │ │ ├── cleanup.go │ │ │ ├── containers.go │ │ │ ├── db.go │ │ │ └── server.go │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ ├── middleware/ │ │ │ └── auth.go │ │ └── monitoring/ │ │ └── monitor.go │ └── schedules/ │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── queue.ts │ │ ├── schema.ts │ │ ├── utils.ts │ │ └── workers.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── biome.json ├── package.json ├── packages/ │ └── server/ │ ├── auth-schema.ts │ ├── auth-schema2.ts │ ├── esbuild.config.ts │ ├── package.json │ ├── scripts/ │ │ ├── switchToDist.js │ │ └── switchToSrc.js │ ├── src/ │ │ ├── auth/ │ │ │ └── random-password.ts │ │ ├── constants/ │ │ │ └── index.ts │ │ ├── db/ │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── schema/ │ │ │ │ ├── account.ts │ │ │ │ ├── ai.ts │ │ │ │ ├── application.ts │ │ │ │ ├── audit-log.ts │ │ │ │ ├── backups.ts │ │ │ │ ├── bitbucket.ts │ │ │ │ ├── certificate.ts │ │ │ │ ├── compose.ts │ │ │ │ ├── dbml.ts │ │ │ │ ├── deployment.ts │ │ │ │ ├── destination.ts │ │ │ │ ├── domain.ts │ │ │ │ ├── environment.ts │ │ │ │ ├── git-provider.ts │ │ │ │ ├── gitea.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mariadb.ts │ │ │ │ ├── mongo.ts │ │ │ │ ├── mount.ts │ │ │ │ ├── mysql.ts │ │ │ │ ├── notification.ts │ │ │ │ ├── patch.ts │ │ │ │ ├── port.ts │ │ │ │ ├── postgres.ts │ │ │ │ ├── preview-deployments.ts │ │ │ │ ├── project.ts │ │ │ │ ├── redirects.ts │ │ │ │ ├── redis.ts │ │ │ │ ├── registry.ts │ │ │ │ ├── rollbacks.ts │ │ │ │ ├── schedule.ts │ │ │ │ ├── schema.dbml │ │ │ │ ├── security.ts │ │ │ │ ├── server.ts │ │ │ │ ├── session.ts │ │ │ │ ├── shared.ts │ │ │ │ ├── ssh-key.ts │ │ │ │ ├── sso.ts │ │ │ │ ├── tag.ts │ │ │ │ ├── user.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── volume-backups.ts │ │ │ │ └── web-server-settings.ts │ │ │ └── validations/ │ │ │ ├── domain.ts │ │ │ └── index.ts │ │ ├── emails/ │ │ │ ├── .gitignore │ │ │ ├── emails/ │ │ │ │ ├── build-failed.tsx │ │ │ │ ├── build-success.tsx │ │ │ │ ├── database-backup.tsx │ │ │ │ ├── docker-cleanup.tsx │ │ │ │ ├── dokploy-restart.tsx │ │ │ │ ├── invitation.tsx │ │ │ │ ├── notion-magic-link.tsx │ │ │ │ ├── plaid-verify-identity.tsx │ │ │ │ ├── stripe-welcome.tsx │ │ │ │ ├── vercel-invite-user.tsx │ │ │ │ └── volume-backup.tsx │ │ │ ├── package.json │ │ │ └── readme.md │ │ ├── index.ts │ │ ├── lib/ │ │ │ ├── access-control.ts │ │ │ ├── auth.ts │ │ │ └── logger.ts │ │ ├── monitoring/ │ │ │ └── utils.ts │ │ ├── services/ │ │ │ ├── admin.ts │ │ │ ├── ai.ts │ │ │ ├── application.ts │ │ │ ├── backup.ts │ │ │ ├── bitbucket.ts │ │ │ ├── cdn.ts │ │ │ ├── certificate.ts │ │ │ ├── cluster.ts │ │ │ ├── compose.ts │ │ │ ├── deployment.ts │ │ │ ├── destination.ts │ │ │ ├── docker.ts │ │ │ ├── domain.ts │ │ │ ├── environment.ts │ │ │ ├── git-provider.ts │ │ │ ├── gitea.ts │ │ │ ├── github.ts │ │ │ ├── gitlab.ts │ │ │ ├── mariadb.ts │ │ │ ├── mongo.ts │ │ │ ├── mount.ts │ │ │ ├── mysql.ts │ │ │ ├── notification.ts │ │ │ ├── patch-repo.ts │ │ │ ├── patch.ts │ │ │ ├── permission.ts │ │ │ ├── port.ts │ │ │ ├── postgres.ts │ │ │ ├── preview-deployment.ts │ │ │ ├── project.ts │ │ │ ├── proprietary/ │ │ │ │ ├── audit-log.ts │ │ │ │ ├── license-key.ts │ │ │ │ └── sso.ts │ │ │ ├── redirect.ts │ │ │ ├── redis.ts │ │ │ ├── registry.ts │ │ │ ├── rollbacks.ts │ │ │ ├── schedule.ts │ │ │ ├── security.ts │ │ │ ├── server.ts │ │ │ ├── settings.ts │ │ │ ├── ssh-key.ts │ │ │ ├── user.ts │ │ │ ├── volume-backups.ts │ │ │ └── web-server-settings.ts │ │ ├── setup/ │ │ │ ├── config-paths.ts │ │ │ ├── monitoring-setup.ts │ │ │ ├── postgres-setup.ts │ │ │ ├── redis-setup.ts │ │ │ ├── server-audit.ts │ │ │ ├── server-setup.ts │ │ │ ├── server-validate.ts │ │ │ ├── setup.ts │ │ │ └── traefik-setup.ts │ │ ├── templates/ │ │ │ ├── github.ts │ │ │ ├── index.ts │ │ │ └── processors.ts │ │ ├── types/ │ │ │ ├── template.ts │ │ │ └── with.ts │ │ ├── utils/ │ │ │ ├── access-log/ │ │ │ │ ├── handler.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ ├── ai/ │ │ │ │ ├── index.ts │ │ │ │ └── select-ai-provider.ts │ │ │ ├── backups/ │ │ │ │ ├── compose.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mariadb.ts │ │ │ │ ├── mongo.ts │ │ │ │ ├── mysql.ts │ │ │ │ ├── postgres.ts │ │ │ │ ├── utils.ts │ │ │ │ └── web-server.ts │ │ │ ├── builders/ │ │ │ │ ├── compose.ts │ │ │ │ ├── docker-file.ts │ │ │ │ ├── drop.ts │ │ │ │ ├── heroku.ts │ │ │ │ ├── index.ts │ │ │ │ ├── nixpacks.ts │ │ │ │ ├── paketo.ts │ │ │ │ ├── railpack.ts │ │ │ │ ├── static.ts │ │ │ │ └── utils.ts │ │ │ ├── cluster/ │ │ │ │ └── upload.ts │ │ │ ├── crons/ │ │ │ │ └── enterprise.ts │ │ │ ├── databases/ │ │ │ │ ├── mariadb.ts │ │ │ │ ├── mongo.ts │ │ │ │ ├── mysql.ts │ │ │ │ ├── postgres.ts │ │ │ │ ├── rebuild.ts │ │ │ │ └── redis.ts │ │ │ ├── docker/ │ │ │ │ ├── collision/ │ │ │ │ │ └── root-network.ts │ │ │ │ ├── collision.ts │ │ │ │ ├── compose/ │ │ │ │ │ ├── configs.ts │ │ │ │ │ ├── network.ts │ │ │ │ │ ├── secrets.ts │ │ │ │ │ ├── service.ts │ │ │ │ │ └── volume.ts │ │ │ │ ├── compose.ts │ │ │ │ ├── domain.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ ├── filesystem/ │ │ │ │ ├── directory.ts │ │ │ │ └── ssh.ts │ │ │ ├── gpu-setup.ts │ │ │ ├── notifications/ │ │ │ │ ├── build-error.ts │ │ │ │ ├── build-success.ts │ │ │ │ ├── database-backup.ts │ │ │ │ ├── docker-cleanup.ts │ │ │ │ ├── dokploy-restart.ts │ │ │ │ ├── server-threshold.ts │ │ │ │ ├── utils.ts │ │ │ │ └── volume-backup.ts │ │ │ ├── process/ │ │ │ │ ├── ExecError.ts │ │ │ │ ├── execAsync.ts │ │ │ │ └── spawnAsync.ts │ │ │ ├── providers/ │ │ │ │ ├── bitbucket.ts │ │ │ │ ├── docker.ts │ │ │ │ ├── git.ts │ │ │ │ ├── gitea.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ └── raw.ts │ │ │ ├── restore/ │ │ │ │ ├── compose.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mariadb.ts │ │ │ │ ├── mongo.ts │ │ │ │ ├── mysql.ts │ │ │ │ ├── postgres.ts │ │ │ │ ├── utils.ts │ │ │ │ └── web-server.ts │ │ │ ├── schedules/ │ │ │ │ ├── index.ts │ │ │ │ └── utils.ts │ │ │ ├── servers/ │ │ │ │ └── remote-docker.ts │ │ │ ├── startup/ │ │ │ │ └── cancell-deployments.ts │ │ │ ├── tracking/ │ │ │ │ └── hubspot.ts │ │ │ ├── traefik/ │ │ │ │ ├── application.ts │ │ │ │ ├── domain.ts │ │ │ │ ├── file-types.ts │ │ │ │ ├── middleware.ts │ │ │ │ ├── redirect.ts │ │ │ │ ├── security.ts │ │ │ │ ├── types.ts │ │ │ │ └── web-server.ts │ │ │ ├── volume-backups/ │ │ │ │ ├── backup.ts │ │ │ │ ├── index.ts │ │ │ │ ├── restore.ts │ │ │ │ └── utils.ts │ │ │ └── watch-paths/ │ │ │ └── should-deploy.ts │ │ ├── verification/ │ │ │ └── send-verification-email.tsx │ │ └── wss/ │ │ └── utils.ts │ ├── tsconfig.json │ ├── tsconfig.server.json │ └── tsconfig.server.no-decl.json └── pnpm-workspace.yaml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ # Dockerfile for DevContainer FROM node:24.4.0-bullseye-slim # Install essential packages RUN apt-get update && apt-get install -y \ curl \ bash \ git \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Set up PNPM ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable && corepack prepare pnpm@10.22.0 --activate # Create workspace directory WORKDIR /workspaces/dokploy # Set up user permissions USER node ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "Dokploy development container", "build": { "dockerfile": "Dockerfile", "context": ".." }, "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": { "moby": true, "version": "latest" }, "ghcr.io/devcontainers/features/git:1": { "ppa": true, "version": "latest" }, "ghcr.io/devcontainers/features/go:1": { "version": "1.20" } }, "customizations": { "vscode": { "extensions": [ "ms-vscode.vscode-typescript-next", "bradlc.vscode-tailwindcss", "ms-vscode.vscode-json", "biomejs.biome", "golang.go", "redhat.vscode-xml", "github.vscode-github-actions", "github.copilot", "github.copilot-chat" ] } }, "forwardPorts": [3000, 5432, 6379], "portsAttributes": { "3000": { "label": "Dokploy App", "onAutoForward": "notify" }, "5432": { "label": "PostgreSQL", "onAutoForward": "silent" }, "6379": { "label": "Redis", "onAutoForward": "silent" } }, "remoteUser": "node", "workspaceFolder": "/workspaces/dokploy", "runArgs": ["--name", "dokploy-devcontainer"] } ================================================ FILE: .dockerignore ================================================ node_modules .git .gitignore *.md dist ================================================ FILE: .github/CODEOWNERS ================================================ # These owners will be the default owners for everything in * @siumauricio ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [siumauricio] patreon: # open_collective: dokploy ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Create a bug report labels: ["needs-triage🔍"] body: - type: markdown attributes: value: | Before opening a new issue, please do a search of existing issues. If you need help with your own project, you can start a discussion in the [Q&A Section](https://github.com/Dokploy/dokploy/discussions). - type: textarea attributes: label: To Reproduce description: | A detailed, step-by-step description of how to reproduce the issue is required. Please ensure your report includes clear instructions using numbered lists. If possible, provide a link to a repository or project where the issue can be reproduced. placeholder: | 1. Create a application 2. Click X 3. Y will happen Make sure to: - Use numbered lists to outline steps clearly. - Include all relevant commands and configurations. - Provide a link to a reproducible repository if applicable. validations: required: true - type: textarea attributes: label: Current vs. Expected behavior description: A clear and concise description of what the bug is, and what you expected to happen. placeholder: "Following the steps from the previous section, I expected A to happen, but I observed B instead" validations: required: true - type: textarea attributes: label: Provide environment information description: Please provide the following information about your environment. render: bash placeholder: | Operating System: OS: Ubuntu 20.04 Arch: arm64 Dokploy version: 0.2.2' VPS Provider: DigitalOcean, Hetzner, Linode, etc. What applications/services are you tying to deploy? eg - Database, Nextjs App, laravel, etc. validations: required: true - type: dropdown attributes: label: Which area(s) are affected? (Select all that apply) multiple: true options: - "Installation" - "Application" - "Databases" - "Docker Compose" - "Traefik" - "Docker" - "Remote server" - "Local Development" - "Cloud Version" validations: required: true - type: dropdown attributes: label: Are you deploying the applications where Dokploy is installed or on a remote server? options: - "Same server where Dokploy is installed" - "Remote server" - "Both" validations: required: true - type: textarea attributes: label: Additional context description: | Any extra information that might help us investigate. placeholder: | I tested on a DigitalOcean VPS with Ubuntu 20.04 and Docker version 20.10.12. - type: dropdown attributes: label: Will you send a PR to fix it? description: Let us know if you are planning to submit a pull request to address this issue. options: - "Yes" - "No" - "Maybe, need help" validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Questions? url: https://github.com/Dokploy/dokploy/discussions about: Ask your questions here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request.yml ================================================ name: Feature Request description: Suggest a new feature or improvement to the project labels: ["enhancement"] body: - type: textarea attributes: label: What problem will this feature address? description: A clear and concise description of what the problem is. placeholder: | I'm always frustrated when I can't do X validations: required: true - type: textarea attributes: label: Describe the solution you'd like description: A clear and concise description of what you want to happen. placeholder: Add X to the core validations: required: true - type: textarea attributes: label: Describe alternatives you've considered description: A clear and concise description of any alternative solutions or features you've considered. placeholder: | Maybe use Y as a workaround? validations: required: true - type: textarea attributes: label: Additional context description: Add any other context or screenshots about the feature request here. validations: required: false - type: dropdown attributes: label: Will you send a PR to implement it? description: Let us know if you are planning to submit a pull request to implement this feature. options: - "Yes" - "No" - "Maybe, need help" validations: required: true ================================================ FILE: .github/pull_request_template.md ================================================ ## What is this PR about? Please describe in a short paragraph what this PR is about. ## Checklist 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. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you. ## Issues related (if applicable) closes #123 ## Screenshots (if applicable) ================================================ FILE: .github/workflows/create-pr.yml ================================================ name: Auto PR to main when version changes on: push: branches: - canary permissions: contents: write pull-requests: write jobs: create-pr: runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Get version from package.json run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV - name: Get latest GitHub tag run: | LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1) echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV echo $LATEST_TAG - name: Compare versions run: | if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then VERSION_CHANGED="true" else VERSION_CHANGED="false" fi echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV echo "Comparing versions:" echo "Current version: ${{ env.VERSION }}" echo "Latest tag: ${{ env.LATEST_TAG }}" echo "Version changed: $VERSION_CHANGED" - name: Check if a PR already exists run: | PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length') echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV env: GH_TOKEN: ${{ secrets.GH_PAT }} - name: Create Pull Request if: env.VERSION_CHANGED == 'true' && env.PR_EXISTS == '0' run: | git config --global user.name "github-actions[bot]" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git fetch origin main git checkout canary git push origin canary gh pr create \ --title "🚀 Release ${{ env.VERSION }}" \ --body ' This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}. ### 🔍 Changes Include: - Version bump to ${{ env.VERSION }} - All changes from canary branch ### ✅ Pre-merge Checklist: - [ ] All tests passing - [ ] Documentation updated - [ ] Docker images built and tested > 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \ --base main \ --head canary \ --label "release" --label "automated pr" || true \ --reviewer siumauricio \ --assignee siumauricio env: GH_TOKEN: ${{ github.token }} ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Build Docker images on: push: branches: [main, canary] workflow_dispatch: jobs: build-and-push-cloud-image: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - name: Set tag and version id: meta-cloud run: | VERSION=$(jq -r .version apps/dokploy/package.json) echo "version=$VERSION" >> $GITHUB_OUTPUT if [ "${{ github.ref }}" = "refs/heads/main" ]; then echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT else echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT fi - name: Log in to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v4 with: context: . file: ./Dockerfile.cloud push: true tags: ${{ steps.meta-cloud.outputs.tags }} platforms: linux/amd64 build-args: | NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }} NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }} NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} build-and-push-schedule-image: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - name: Set tag and version id: meta-schedule run: | VERSION=$(jq -r .version apps/dokploy/package.json) if [ "${{ github.ref }}" = "refs/heads/main" ]; then echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT else echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT fi - name: Log in to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v4 with: context: . file: ./Dockerfile.schedule push: true tags: ${{ steps.meta-schedule.outputs.tags }} platforms: linux/amd64 build-and-push-server-image: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - name: Set tag and version id: meta-server run: | VERSION=$(jq -r .version apps/dokploy/package.json) if [ "${{ github.ref }}" = "refs/heads/main" ]; then echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT else echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT fi - name: Log in to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v4 with: context: . file: ./Dockerfile.server push: true tags: ${{ steps.meta-server.outputs.tags }} platforms: linux/amd64 ================================================ FILE: .github/workflows/dokploy.yml ================================================ name: Dokploy Docker Build on: push: branches: [main, canary, "fix/re-apply-database-migration-fix"] workflow_dispatch: env: IMAGE_NAME: dokploy/dokploy jobs: docker-amd: runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set tag and version id: meta run: | if [ "${{ github.ref }}" = "refs/heads/main" ]; then TAG="latest" VERSION=$(node -p "require('./apps/dokploy/package.json').version") elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT - name: Prepare env file run: | cp apps/dokploy/.env.production.example .env.production cp apps/dokploy/.env.production.example apps/dokploy/.env.production - name: Build and push uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64 push: true tags: ${{ steps.meta.outputs.tags }} docker-arm: runs-on: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set tag and version id: meta run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") if [ "${{ github.ref }}" = "refs/heads/main" ]; then TAG="latest" VERSION=$(node -p "require('./apps/dokploy/package.json').version") elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT - name: Prepare env file run: | cp apps/dokploy/.env.production.example .env.production cp apps/dokploy/.env.production.example apps/dokploy/.env.production - name: Build and push uses: docker/build-push-action@v5 with: context: . platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} combine-manifests: needs: [docker-amd, docker-arm] runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push manifests run: | if [ "${{ github.ref }}" = "refs/heads/main" ]; then VERSION=$(node -p "require('./apps/dokploy/package.json').version") TAG="latest" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 docker buildx imagetools create -t ${IMAGE_NAME}:${VERSION} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then TAG="canary" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 else TAG="feature" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 fi generate-release: needs: [combine-manifests] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Get version id: get_version run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Create Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.get_version.outputs.version }} name: ${{ steps.get_version.outputs.version }} generate_release_notes: true draft: false prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/format.yml ================================================ name: autofix.ci on: push: branches: [canary] pull_request: branches: [canary] jobs: format: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup biomeJs uses: biomejs/setup-biome@v2 - name: Run Biome formatter run: biome format --write - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2 ================================================ FILE: .github/workflows/monitoring.yml ================================================ name: Dokploy Monitoring Build on: push: branches: [main, canary] env: IMAGE_NAME: dokploy/monitoring jobs: docker-amd: runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set tag id: meta run: | if [ "${{ github.ref }}" = "refs/heads/main" ]; then TAG="latest" elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT - name: Build and push uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile.monitoring platforms: linux/amd64 push: true tags: ${{ steps.meta.outputs.tags }} docker-arm: runs-on: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set id: meta run: | if [ "${{ github.ref }}" = "refs/heads/main" ]; then TAG="latest" elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT - name: Build and push uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile.monitoring platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} combine-manifests: needs: [docker-amd, docker-arm] runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push manifests run: | if [ "${{ github.ref }}" = "refs/heads/main" ]; then TAG="latest" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then TAG="canary" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 else TAG="feature" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 fi ================================================ FILE: .github/workflows/pr-quality.yml ================================================ name: PR Quality permissions: contents: read issues: read pull-requests: write on: pull_request_target: types: [opened, reopened] jobs: anti-slop: runs-on: ubuntu-latest steps: - uses: peakoss/anti-slop@v0 with: max-failures: 4 blocked-commit-authors: "claude,copilot" require-description: true min-account-age: 5 ================================================ FILE: .github/workflows/pull-request.yml ================================================ name: Pull Request on: pull_request: branches: [main, canary] permissions: contents: read jobs: pr-check: runs-on: ubuntu-latest strategy: matrix: job: [build, test, typecheck] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24.4.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 }} ================================================ FILE: .github/workflows/sync-openapi-docs.yml ================================================ 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: 24.4.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" ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Dependencies node_modules .pnp .pnp.js .docker # Local env files .env .env.local .env.development.local .env.test.local .env.production.local openapi.json # Testing coverage # Turbo .turbo # Vercel .vercel # Build Outputs .next/ out/ dist # Debug npm-debug.log* yarn-debug.log* yarn-error.log* # Editor .idea # Misc .DS_Store *.pem .db ================================================ FILE: .nvmrc ================================================ 24.4.0 ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["biomejs.biome"] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" } } ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute. Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues. We have a few guidelines to follow when contributing to this project: - [Commit Convention](#commit-convention) - [Setup](#setup) - [Development](#development) - [Build](#build) - [Pull Request](#pull-request) - [Important Considerations](#important-considerations-for-pull-requests) ## Commit Convention Before you create a Pull Request, please make sure your commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. ### Commit Message Format ``` [optional scope]: [optional body] [optional footer(s)] ``` #### Type Must be one of the following: - **feat**: A new feature - **fix**: A bug fix - **docs**: Documentation only changes - **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) - **refactor**: A code change that neither fixes a bug nor adds a feature - **perf**: A code change that improves performance - **test**: Adding missing tests or correcting existing tests - **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) - **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) - **chore**: Other changes that don't modify `src` or `test` files - **revert**: Reverts a previous commit Example: ``` feat: add new feature ``` ## Setup Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory. ```bash git clone https://github.com/dokploy/dokploy.git cd dokploy pnpm install cp apps/dokploy/.env.example apps/dokploy/.env ``` ## Requirements - [Docker](/GUIDES.md#docker) ### Setup Run the command that will spin up all the required services and files. ```bash pnpm run dokploy:setup ``` Run this script ```bash pnpm run server:script ``` Now run the development server. ```bash pnpm run dokploy:dev ``` Go to http://localhost:3000 to see the development server > [!NOTE] > This project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off. ## Build ```bash pnpm run dokploy:build ``` ## Docker To build the docker image ```bash pnpm run docker:build ``` To push the docker image ```bash pnpm run docker:push ``` ## Password Reset In the case you lost your password, you can reset it using the following command ```bash pnpm run reset-password ``` If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/) ```bash pnpm dlx localtunnel --port 3000 ``` If you run into permission issues of docker run the following command ```bash sudo chown -R USERNAME dokploy or sudo chown -R $(whoami) ~/.docker ``` ## Application deploy In case you want to deploy the application on your machine and you selected nixpacks or buildpacks, you need to install first. ```bash # Install Nixpacks curl -sSL https://nixpacks.com/install.sh -o install.sh \ && chmod +x install.sh \ && ./install.sh ``` ```bash # Install Railpack curl -sSL https://railpack.com/install.sh | sh ``` ```bash # Install Buildpacks 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 - The `canary` branch is the source of truth and should always reflect the latest stable release. - Create a new branch for each feature or bug fix. - Make sure to add tests for your changes. - Make sure to update the documentation for any changes Go to the [docs.dokploy.com](https://docs.dokploy.com) website to see the changes. - When creating a pull request, please provide a clear and concise description of the changes made. - If you include a video or screenshot, would be awesome so we can see the changes in action. - If your pull request fixes an open issue, please reference the issue in the pull request description. - Once your pull request is merged, you will be automatically added as a contributor to the project. ### Important Considerations for Pull Requests - **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first. - **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects. - **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task. - **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`). - **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing. Thank you for your contribution! ## Templates To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file. ### Recommendations - Use the same name of the folder as the id of the template. - The logo should be in the public folder. - If you want to show a domain in the UI, please add the `_HOST` suffix at the end of the variable name. - Test first on a vps or a server to make sure the template works. ## Docs & Website To contribute to the Dokploy docs or website, please go to this [repository](https://github.com/Dokploy/website). ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1 FROM node:24.4.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app WORKDIR /usr/src/app RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/* # Install dependencies RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile # Deploy only the dokploy app ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/dokploy run build RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist FROM base AS dokploy WORKDIR /app # Set production ENV NODE_ENV=production RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/* # Copy only the necessary files COPY --from=build /prod/dokploy/.next ./.next COPY --from=build /prod/dokploy/dist ./dist COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs COPY --from=build /prod/dokploy/public ./public COPY --from=build /prod/dokploy/package.json ./package.json COPY --from=build /prod/dokploy/drizzle ./drizzle COPY .env.production ./.env COPY --from=build /prod/dokploy/components.json ./components.json 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 --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.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.15.4 RUN curl -sSL https://railpack.com/install.sh | bash # Install buildpacks COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack EXPOSE 3000 HEALTHCHECK --interval=10s --timeout=3s --retries=10 \ CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1 CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"] ================================================ FILE: Dockerfile.cloud ================================================ # syntax=docker/dockerfile:1 FROM node:24.4.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app WORKDIR /usr/src/app RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/* # Install dependencies RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server --filter=./apps/dokploy install --frozen-lockfile # Deploy only the dokploy app # 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_STRIPE_PUBLISHABLE_KEY ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/dokploy run build RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist FROM base AS dokploy WORKDIR /app # Set production ENV NODE_ENV=production RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var/lib/apt/lists/* # Copy only the necessary files COPY --from=build /prod/dokploy/.next ./.next COPY --from=build /prod/dokploy/dist ./dist COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs COPY --from=build /prod/dokploy/public ./public COPY --from=build /prod/dokploy/package.json ./package.json COPY --from=build /prod/dokploy/drizzle ./drizzle COPY --from=build /prod/dokploy/components.json ./components.json COPY --from=build /prod/dokploy/node_modules ./node_modules # Install RCLONE RUN curl https://rclone.org/install.sh | bash # tsx RUN pnpm install -g tsx EXPOSE 3000 CMD [ "pnpm", "start" ] ================================================ FILE: Dockerfile.monitoring ================================================ # syntax=docker/dockerfile:1 # Build stage FROM golang:1.21-alpine3.19 AS builder # Instalar dependencias necesarias RUN apk add --no-cache gcc musl-dev sqlite-dev # Establecer el directorio de trabajo WORKDIR /app # Copiar todo el código fuente primero COPY . . # Movernos al directorio de la aplicación golang WORKDIR /app/apps/monitoring # Descargar dependencias RUN go mod download # Compilar la aplicación RUN CGO_ENABLED=1 GOOS=linux go build -o main main.go # Etapa final FROM alpine:3.19 # Instalar SQLite y otras dependencias necesarias RUN apk add --no-cache sqlite-libs docker-cli WORKDIR /app # Copiar el binario compilado y el archivo monitor.go COPY --from=builder /app/apps/monitoring/main ./main COPY --from=builder /app/apps/monitoring/main.go ./monitor.go # COPY --from=builder /app/apps/golang/.env ./.env # Exponer el puerto ENV PORT=3001 EXPOSE 3001 # Ejecutar la aplicación CMD ["./main"] ================================================ FILE: Dockerfile.schedule ================================================ # syntax=docker/dockerfile:1 FROM node:24.4.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app WORKDIR /usr/src/app RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/* # Install dependencies RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server --filter=./apps/schedules install --frozen-lockfile # Deploy only the dokploy app ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/schedules run build RUN pnpm --filter=./apps/schedules --prod deploy --legacy /prod/schedules RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist FROM base AS dokploy WORKDIR /app # Set production ENV NODE_ENV=production # Copy only the necessary files 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 ENV HOSTNAME=0.0.0.0 CMD ["pnpm", "start"] ================================================ FILE: Dockerfile.server ================================================ # syntax=docker/dockerfile:1 FROM node:24.4.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app WORKDIR /usr/src/app RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/* # Install dependencies RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server --filter=./apps/api install --frozen-lockfile # Deploy only the dokploy app ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/api run build RUN pnpm --filter=./apps/api --prod deploy --legacy /prod/api RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist FROM base AS dokploy WORKDIR /app # Set production ENV NODE_ENV=production # Copy only the necessary files 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 ENV HOSTNAME=0.0.0.0 CMD ["pnpm", "start"] ================================================ FILE: GUIDES.md ================================================ # Docker Here's how to install docker on different operating systems: ## macOS 1. Visit [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop) 2. Download the Docker Desktop installer 3. Double-click the downloaded `.dmg` file 4. Drag Docker to your Applications folder 5. Open Docker Desktop from Applications 6. Follow the onboarding tutorial if desired ## Linux ### Ubuntu ```bash # Uninstall old versions for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done # Update package index sudo apt-get update # Install prerequisites sudo apt-get install ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings # Add Docker's official GPG key sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc # Add the repository to Apt sources echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker Engine sudo apt-get update sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` ## Windows 1. Enable WSL2 if not already enabled 2. Visit [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop) 3. Download the installer 4. Run the installer and follow the prompts 5. Start Docker Desktop from the Start menu ================================================ FILE: LICENSE.MD ================================================ Copyright 2026-present Dokploy Technology, Inc. Portions of this software are licensed as follows: * 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. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software 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. ================================================ FILE: LICENSE_PROPRIETARY.md ================================================ 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. ================================================ FILE: README.md ================================================
Dokploy - Open Source Alternative to Vercel, Heroku and Netlify.

Join us on Discord for help, feedback, and discussions!

Discord Shield

Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases. ## ✨ Features Dokploy includes multiple features to make your life easier. - **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.). - **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis. - **Backups**: Automate backups for databases to an external storage destination. - **Docker Compose**: Native support for Docker Compose to manage complex applications. - **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster. - **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click. - **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing. - **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource. - **Docker Management**: Easily deploy and manage Docker containers. - **CLI/API**: Manage your applications and databases using the command line or through the API. - **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.). - **Multi Server**: Deploy and manage your applications remotely to external servers. - **Self-Hosted**: Self-host Dokploy on your VPS. ## 🚀 Getting Started To get started, run the following command on a VPS: Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com). ```bash curl -sSL https://dokploy.com/install.sh | sh ``` For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). [Github Sponsors](https://github.com/sponsors/Siumauricio) ### Contributors 🤝 Contributors ## 📺 Video Tutorial Watch the video ## 🤝 Contributing Check out the [Contributing Guide](CONTRIBUTING.md) for more information. ================================================ FILE: SECURITY.md ================================================ # Dokploy Security Policy At Dokploy, security is a top priority. We appreciate the help of security researchers and the community in identifying and reporting vulnerabilities. ## How to Report a Vulnerability If you have discovered a security vulnerability in Dokploy, we ask that you report it responsibly by following these guidelines: 1. **Contact us:** Send an email to [contact@dokploy.com](mailto:contact@dokploy.com). 2. **Provide clear details:** Include as much information as possible to help us understand and reproduce the vulnerability. This should include: * A clear description of the vulnerability. * Steps to reproduce the vulnerability. * Any sample code, screenshots, or videos that might be helpful. * The potential impact of the vulnerability. 3. **Do not make the vulnerability public:** Please refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address it. This is crucial for protecting our users. 4. **Allow us time:** We will endeavor to acknowledge receipt of your report as soon as possible and keep you informed of our progress. The time to resolve the vulnerability may vary depending on its complexity and severity. ## What We Expect From You * Do not access user data or systems beyond what is necessary to demonstrate the vulnerability. * Do not perform denial-of-service (DoS) attacks, spamming, or social engineering. * Do not modify or destroy data that does not belong to you. ## Our Commitment We are committed to working with you quickly and responsibly to address any legitimate security vulnerability. Thank you for helping us keep Dokploy secure for everyone. ================================================ FILE: TERMS_AND_CONDITIONS.md ================================================ # Terms & Conditions **Dokploy core** is a free and open-source solution intended as an alternative to established cloud platforms like Vercel and Netlify. The Dokploy team endeavors to mitigate potential defects and issues through stringent testing and adherence to principles of clean coding. Dokploy is provided "AS IS" without any warranties, express or implied. Refer to the [License](https://github.com/Dokploy/Dokploy/blob/main/LICENSE) for details on permissions and restrictions. ### Description of Service: **Dokploy core** is an open-source tool designed to simplify the deployment of applications for both personal and business use. Users are permitted to install, modify, and operate Dokploy independently or within their organizations to improve their development and deployment operations. It is important to note that any commercial resale or redistribution of Dokploy as a service is strictly forbidden without explicit consent. This prohibition ensures the preservation of Dokploy's open-source character for the benefit of the entire community. ### Our Responsibility The Dokploy development team commits to maintaining the functionality of the software and addressing major issues promptly. While we welcome suggestions for new features, the decision to include them rests solely with the core developers of Dokploy. ### Usage Data **Dokploy** does not collect any user data. It is distributed as a free and open-source tool under the terms of "AS IS", without any implied warranties or conditions. ### Future Changes The Terms of Service and Terms & Conditions are subject to change without prior notice. ================================================ FILE: apps/api/.gitignore ================================================ # dev .yarn/ !.yarn/releases .vscode/* !.vscode/launch.json !.vscode/*.code-snippets .idea/workspace.xml .idea/usage.statistics.xml .idea/shelf # deps node_modules/ # env .env .env.production # logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* # misc .DS_Store ================================================ FILE: apps/api/README.md ================================================ ``` npm install npm run dev ``` ``` open http://localhost:3000 ``` ================================================ FILE: apps/api/package.json ================================================ { "name": "@dokploy/api", "version": "0.0.1", "type": "module", "scripts": { "dev": "PORT=4000 tsx watch src/index.ts", "build": "rimraf dist && tsc --project tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc --noEmit" }, "dependencies": { "inngest": "3.40.1", "@dokploy/server": "workspace:*", "@hono/node-server": "^1.14.3", "@hono/zod-validator": "0.7.6", "dotenv": "^16.4.5", "hono": "^4.11.7", "pino": "9.4.0", "pino-pretty": "11.2.2", "react": "18.2.0", "react-dom": "18.2.0", "redis": "4.7.0", "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^24.4.0", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", "rimraf": "6.1.3", "tsx": "^4.16.2", "typescript": "^5.8.3" }, "packageManager": "pnpm@10.22.0", "engines": { "node": "^24.4.0", "pnpm": ">=10.22.0" } } ================================================ FILE: apps/api/src/index.ts ================================================ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import "dotenv/config"; import { zValidator } from "@hono/zod-validator"; import { Inngest } from "inngest"; import { serve as serveInngest } from "inngest/hono"; import { logger } from "./logger.js"; import { cancelDeploymentSchema, type DeployJob, deployJobSchema, } from "./schema.js"; import { fetchDeploymentJobs } from "./service.js"; import { deploy } from "./utils.js"; const app = new Hono(); // Initialize Inngest client export const inngest = new Inngest({ id: "dokploy-deployments", name: "Dokploy Deployment Service", }); export const deploymentFunction = inngest.createFunction( { id: "deploy-application", name: "Deploy Application", concurrency: [ { key: "event.data.serverId", limit: 1, }, ], retries: 0, cancelOn: [ { event: "deployment/cancelled", if: "async.data.applicationId == event.data.applicationId || async.data.composeId == event.data.composeId", timeout: "1h", // Allow cancellation for up to 1 hour }, ], }, { event: "deployment/requested" }, async ({ event, step }) => { const jobData = event.data as DeployJob; return await step.run("execute-deployment", async () => { logger.info("Deploying started"); try { const result = await deploy(jobData); logger.info("Deployment finished", result); // Send success event await inngest.send({ name: "deployment/completed", data: { ...jobData, result, status: "success", }, }); return result; } catch (error) { logger.error("Deployment failed", { jobData, error }); // Send failure event await inngest.send({ name: "deployment/failed", data: { ...jobData, error: error instanceof Error ? error.message : String(error), status: "failed", }, }); throw error; } }); }, ); app.use(async (c, next) => { if (c.req.path === "/health" || c.req.path === "/api/inngest") { return next(); } const authHeader = c.req.header("X-API-Key"); if (process.env.API_KEY !== authHeader) { return c.json({ message: "Invalid API Key" }, 403); } return next(); }); app.post("/deploy", zValidator("json", deployJobSchema), async (c) => { const data = c.req.valid("json"); logger.info("Received deployment request", data); try { // Send event to Inngest instead of adding to Redis queue await inngest.send({ name: "deployment/requested", data, }); logger.info("Deployment event sent to Inngest", { serverId: data.serverId, }); return c.json( { message: "Deployment Added to Inngest Queue", serverId: data.serverId, }, 200, ); } catch (error) { logger.error("Failed to send deployment event", error); return c.json( { message: "Failed to queue deployment", error: error instanceof Error ? error.message : String(error), }, 500, ); } }); app.post( "/cancel-deployment", zValidator("json", cancelDeploymentSchema), async (c) => { const data = c.req.valid("json"); logger.info("Received cancel deployment request", data); try { // Send cancellation event to Inngest await inngest.send({ name: "deployment/cancelled", data, }); const identifier = data.applicationType === "application" ? `applicationId: ${data.applicationId}` : `composeId: ${data.composeId}`; logger.info("Deployment cancellation event sent", { ...data, identifier, }); return c.json({ message: "Deployment cancellation requested", applicationType: data.applicationType, }); } catch (error) { logger.error("Failed to send deployment cancellation event", error); return c.json( { message: "Failed to cancel deployment", error: error instanceof Error ? error.message : String(error), }, 500, ); } }, ); app.get("/health", async (c) => { return c.json({ status: "ok" }); }); // List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI app.get("/jobs", async (c) => { const serverId = c.req.query("serverId"); if (!serverId) { return c.json({ message: "serverId is required" }, 400); } try { const rows = await fetchDeploymentJobs(serverId); return c.json(rows); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("INNGEST_BASE_URL")) { return c.json( { message: "INNGEST_BASE_URL is required to list deployment jobs" }, 503, ); } logger.error("Failed to fetch jobs from Inngest", { serverId, error }); return c.json([], 200); } }); // Serve Inngest functions endpoint app.on( ["GET", "POST", "PUT"], "/api/inngest", serveInngest({ client: inngest, functions: [deploymentFunction], }), ); const port = Number.parseInt(process.env.PORT || "3000"); logger.info("Starting Deployments Server with Inngest ✅", port); serve({ fetch: app.fetch, port }); ================================================ FILE: apps/api/src/logger.ts ================================================ import pino from "pino"; export const logger = pino({ transport: { target: "pino-pretty", options: { colorize: true, }, }, }); ================================================ FILE: apps/api/src/schema.ts ================================================ import { z } from "zod"; export const deployJobSchema = z.discriminatedUnion("applicationType", [ z.object({ applicationId: z.string(), titleLog: z.string().optional(), descriptionLog: z.string().optional(), server: z.boolean().optional(), type: z.enum(["deploy", "redeploy"]), applicationType: z.literal("application"), serverId: z.string().min(1), }), z.object({ composeId: z.string(), titleLog: z.string().optional(), descriptionLog: z.string().optional(), server: z.boolean().optional(), type: z.enum(["deploy", "redeploy"]), applicationType: z.literal("compose"), serverId: z.string().min(1), }), z.object({ applicationId: z.string(), previewDeploymentId: z.string(), titleLog: z.string().optional(), descriptionLog: z.string().optional(), server: z.boolean().optional(), type: z.enum(["deploy", "redeploy"]), applicationType: z.literal("application-preview"), serverId: z.string().min(1), }), ]); export type DeployJob = z.infer; export const cancelDeploymentSchema = z.discriminatedUnion("applicationType", [ z.object({ applicationId: z.string(), applicationType: z.literal("application"), }), z.object({ composeId: z.string(), applicationType: z.literal("compose"), }), ]); export type CancelDeploymentJob = z.infer; ================================================ FILE: apps/api/src/service.ts ================================================ import { logger } from "./logger.js"; const baseUrl = process.env.INNGEST_BASE_URL ?? ""; const signingKey = process.env.INNGEST_SIGNING_KEY ?? ""; const DEFAULT_MAX_EVENTS = 500; const MAX_EVENTS = DEFAULT_MAX_EVENTS; /** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */ type InngestEventRow = { internal_id?: string; accountID?: string; environmentID?: string; source?: string; sourceID?: string | null; /** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */ receivedAt?: string; received_at?: string; id: string; name: string; data: Record; user?: unknown; ts: number; v?: string | null; metadata?: { fetchedAt: string; cachedUntil: string | null; }; }; /** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */ type InngestRun = { run_id: string; event_id: string; status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"? run_started_at?: string; ended_at?: string | null; output?: unknown; // dev server / API may use different casing run_started_at_ms?: number; }; function getEventReceivedAt(ev: InngestEventRow): string | undefined { return ev.receivedAt ?? ev.received_at; } /** Map Inngest run status to BullMQ-style state for the UI */ function runStatusToState( status: string, ): "pending" | "active" | "completed" | "failed" | "cancelled" { const s = status.toLowerCase(); if (s === "running") return "active"; if (s === "completed") return "completed"; if (s === "failed") return "failed"; if (s === "cancelled") return "cancelled"; if (s === "queued") return "pending"; return "pending"; } export const fetchInngestEvents = async () => { const maxEvents = MAX_EVENTS; const all: InngestEventRow[] = []; let cursor: string | undefined; do { const params = new URLSearchParams({ limit: "100" }); if (cursor) { params.set("cursor", cursor); } const res = await fetch(`${baseUrl}/v1/events?${params}`, { headers: { Authorization: `Bearer ${signingKey}`, "Content-Type": "application/json", }, }); if (!res.ok) { logger.warn("Inngest API error", { status: res.status, body: await res.text(), }); break; } const body = (await res.json()) as { data?: InngestEventRow[]; cursor?: string; nextCursor?: string; }; const data = Array.isArray(body.data) ? body.data : []; all.push(...data); // Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs) const nextCursor = body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id; const hasMore = data.length === 100 && nextCursor && all.length < maxEvents; cursor = hasMore ? nextCursor : undefined; } while (cursor); return all.slice(0, maxEvents); }; /** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */ export const fetchInngestRunsForEvent = async ( eventId: string, ): Promise => { const res = await fetch( `${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`, { headers: { Authorization: `Bearer ${signingKey}`, "Content-Type": "application/json", }, }, ); if (!res.ok) { logger.warn("Inngest runs API error", { eventId, status: res.status, body: await res.text(), }); return []; } const body = (await res.json()) as { data?: InngestRun[] }; return Array.isArray(body.data) ? body.data : []; }; /** One row for the queue UI (BullMQ-compatible shape) */ export type DeploymentJobRow = { id: string; name: string; data: Record; timestamp: number; processedOn?: number; finishedOn?: number; failedReason?: string; state: string; }; /** Build queue rows from events + their runs (one row per run, or pending if no run yet) */ function buildDeploymentRowsFromRuns( events: InngestEventRow[], runsByEventId: Map, serverId: string, ): DeploymentJobRow[] { const requested = events.filter( (e) => e.name === "deployment/requested" && (e.data as Record)?.serverId === serverId, ); const rows: DeploymentJobRow[] = []; for (const ev of requested) { const data = (ev.data ?? {}) as Record; const runs = runsByEventId.get(ev.id) ?? []; if (runs.length === 0) { // Queued: event received but no run yet rows.push({ id: ev.id, name: ev.name, data, timestamp: ev.ts, processedOn: ev.ts, finishedOn: undefined, failedReason: undefined, state: "pending", }); continue; } for (const run of runs) { const state = runStatusToState(run.status); const runStartedMs = run.run_started_at_ms ?? (run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts); const endedMs = run.ended_at ? new Date(run.ended_at).getTime() : undefined; const failedReason = state === "failed" && run.output && typeof run.output === "object" && "error" in run.output ? String((run.output as { error?: unknown }).error) : undefined; rows.push({ id: run.run_id, name: ev.name, data, timestamp: runStartedMs, processedOn: runStartedMs, finishedOn: state === "completed" || state === "failed" || state === "cancelled" ? endedMs : undefined, failedReason, state, }); } } return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); } /** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */ export const fetchDeploymentJobs = async ( serverId: string, ): Promise => { if (!signingKey) { logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list"); return []; } if (!baseUrl) { throw new Error("INNGEST_BASE_URL is required to list deployment jobs"); } const events = await fetchInngestEvents(); const requestedForServer = events.filter( (e) => e.name === "deployment/requested" && (e.data as Record)?.serverId === serverId, ); // Limit to avoid too many run fetches const toFetch = requestedForServer.slice(0, 50); const runsByEventId = new Map(); await Promise.all( toFetch.map(async (ev) => { const runs = await fetchInngestRunsForEvent(ev.id); runsByEventId.set(ev.id, runs); }), ); return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId); }; ================================================ FILE: apps/api/src/utils.ts ================================================ import { deployApplication, deployCompose, deployPreviewApplication, rebuildApplication, rebuildCompose, rebuildPreviewApplication, updateApplicationStatus, updateCompose, updatePreviewDeployment, } from "@dokploy/server"; import type { DeployJob } from "./schema.js"; export const deploy = async (job: DeployJob) => { try { if (job.applicationType === "application") { await updateApplicationStatus(job.applicationId, "running"); if (job.server) { if (job.type === "redeploy") { await rebuildApplication({ applicationId: job.applicationId, titleLog: job.titleLog || "Rebuild deployment", descriptionLog: job.descriptionLog || "", }); } else if (job.type === "deploy") { await deployApplication({ applicationId: job.applicationId, titleLog: job.titleLog || "Manual deployment", descriptionLog: job.descriptionLog || "", }); } } } else if (job.applicationType === "compose") { await updateCompose(job.composeId, { composeStatus: "running", }); if (job.server) { if (job.type === "redeploy") { await rebuildCompose({ composeId: job.composeId, titleLog: job.titleLog || "Rebuild deployment", descriptionLog: job.descriptionLog || "", }); } else if (job.type === "deploy") { await deployCompose({ composeId: job.composeId, titleLog: job.titleLog || "Manual deployment", descriptionLog: job.descriptionLog || "", }); } } } else if (job.applicationType === "application-preview") { await updatePreviewDeployment(job.previewDeploymentId, { previewStatus: "running", }); if (job.server) { 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 || "", previewDeploymentId: job.previewDeploymentId, }); } } } } catch (e) { if (job.applicationType === "application") { await updateApplicationStatus(job.applicationId, "error"); } else if (job.applicationType === "compose") { await updateCompose(job.composeId, { composeStatus: "error", }); } else if (job.applicationType === "application-preview") { await updatePreviewDeployment(job.previewDeploymentId, { previewStatus: "error", }); } throw e; } return true; }; ================================================ FILE: apps/api/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "Node", "strict": true, "skipLibCheck": true, "outDir": "dist", "jsx": "react-jsx", "jsxImportSource": "hono/jsx", "baseUrl": ".", "paths": { "@/*": ["./*"], "@dokploy/server/*": ["../../packages/server/src/*"] } }, "exclude": ["node_modules", "dist"] } ================================================ FILE: apps/dokploy/.dockerignore ================================================ node_modules .git .gitignore *.md dist ================================================ FILE: apps/dokploy/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js /redis-data traefik.yml .docker .env.production # testing /coverage /dist /production-server # database /prisma/db.sqlite /prisma/db.sqlite-journal /logs # next.js /.next/ /out/ next-env.d.ts /dokploy /config # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables .env .env*.local # vercel .vercel # typescript *.tsbuildinfo # otros /.data /.main .vscode *.lockb *.rdb .idea ================================================ FILE: apps/dokploy/__test__/cluster/upload.test.ts ================================================ 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"); }); }); }); ================================================ FILE: apps/dokploy/__test__/compose/compose.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToAllProperties } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFile1 = ` version: "3.8" services: web: image: nginx:latest container_name: web_container depends_on: - app networks: - frontend volumes_from: - data links: - db extends: service: base_service configs: - source: web_config app: image: node:14 networks: - backend - frontend db: image: postgres:13 networks: - backend data: image: busybox volumes: - /data base_service: image: base:latest networks: frontend: driver: bridge backend: driver: bridge volumes: web_data: driver: local configs: web_config: file: ./web_config.yml secrets: db_password: file: ./db_password.txt `; const expectedComposeFile1 = parse(` version: "3.8" services: web-testhash: image: nginx:latest container_name: web_container-testhash depends_on: - app-testhash networks: - frontend-testhash volumes_from: - data-testhash links: - db-testhash extends: service: base_service-testhash configs: - source: web_config-testhash app-testhash: image: node:14 networks: - backend-testhash - frontend-testhash db-testhash: image: postgres:13 networks: - backend-testhash data-testhash: image: busybox volumes: - /data base_service-testhash: image: base:latest networks: frontend-testhash: driver: bridge backend-testhash: driver: bridge volumes: web_data-testhash: driver: local configs: web_config-testhash: file: ./web_config.yml secrets: db_password-testhash: file: ./db_password.txt `) as ComposeSpecification; test("Add suffix to all properties in compose file 1", () => { const composeData = parse(composeFile1) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllProperties(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile1); }); const composeFile2 = ` version: "3.8" services: frontend: image: nginx:latest depends_on: - backend networks: - public volumes_from: - logs links: - cache extends: service: shared_service secrets: - db_password backend: image: node:14 networks: - private - public cache: image: redis:latest networks: - private logs: image: busybox volumes: - /logs shared_service: image: shared:latest networks: public: driver: bridge private: driver: bridge volumes: logs: driver: local configs: app_config: file: ./app_config.yml secrets: db_password: file: ./db_password.txt `; const expectedComposeFile2 = parse(` version: "3.8" services: frontend-testhash: image: nginx:latest depends_on: - backend-testhash networks: - public-testhash volumes_from: - logs-testhash links: - cache-testhash extends: service: shared_service-testhash secrets: - db_password-testhash backend-testhash: image: node:14 networks: - private-testhash - public-testhash cache-testhash: image: redis:latest networks: - private-testhash logs-testhash: image: busybox volumes: - /logs shared_service-testhash: image: shared:latest networks: public-testhash: driver: bridge private-testhash: driver: bridge volumes: logs-testhash: driver: local configs: app_config-testhash: file: ./app_config.yml secrets: db_password-testhash: file: ./db_password.txt `) as ComposeSpecification; test("Add suffix to all properties in compose file 2", () => { const composeData = parse(composeFile2) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllProperties(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile2); }); const composeFile3 = ` version: "3.8" services: service_a: image: service_a:latest depends_on: - service_b networks: - net_a volumes_from: - data_volume links: - service_c extends: service: common_service configs: - source: service_a_config service_b: image: service_b:latest networks: - net_b - net_a service_c: image: service_c:latest networks: - net_b data_volume: image: busybox volumes: - /data common_service: image: common:latest networks: net_a: driver: bridge net_b: driver: bridge volumes: data_volume: driver: local configs: service_a_config: file: ./service_a_config.yml secrets: service_secret: file: ./service_secret.txt `; const expectedComposeFile3 = parse(` version: "3.8" services: service_a-testhash: image: service_a:latest depends_on: - service_b-testhash networks: - net_a-testhash volumes_from: - data_volume-testhash links: - service_c-testhash extends: service: common_service-testhash configs: - source: service_a_config-testhash service_b-testhash: image: service_b:latest networks: - net_b-testhash - net_a-testhash service_c-testhash: image: service_c:latest networks: - net_b-testhash data_volume-testhash: image: busybox volumes: - /data common_service-testhash: image: common:latest networks: net_a-testhash: driver: bridge net_b-testhash: driver: bridge volumes: data_volume-testhash: driver: local configs: service_a_config-testhash: file: ./service_a_config.yml secrets: service_secret-testhash: file: ./service_secret.txt `) as ComposeSpecification; test("Add suffix to all properties in compose file 3", () => { const composeData = parse(composeFile3) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllProperties(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile3); }); const composeFile = ` version: "3.8" services: plausible_db: image: postgres:16-alpine restart: always volumes: - db-data:/var/lib/postgresql/data environment: - POSTGRES_PASSWORD=postgres plausible_events_db: image: clickhouse/clickhouse-server:24.3.3.102-alpine restart: always volumes: - event-data:/var/lib/clickhouse - event-logs:/var/log/clickhouse-server - ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro - ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro ulimits: nofile: soft: 262144 hard: 262144 plausible: image: ghcr.io/plausible/community-edition:v2.1.0 restart: always command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" depends_on: - plausible_db - plausible_events_db ports: - 127.0.0.1:8000:8000 env_file: - plausible-conf.env volumes: db-data: driver: local event-data: driver: local event-logs: driver: local `; const expectedComposeFile = parse(` version: "3.8" services: plausible_db-testhash: image: postgres:16-alpine restart: always volumes: - db-data-testhash:/var/lib/postgresql/data environment: - POSTGRES_PASSWORD=postgres plausible_events_db-testhash: image: clickhouse/clickhouse-server:24.3.3.102-alpine restart: always volumes: - event-data-testhash:/var/lib/clickhouse - event-logs-testhash:/var/log/clickhouse-server - ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro - ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro ulimits: nofile: soft: 262144 hard: 262144 plausible-testhash: image: ghcr.io/plausible/community-edition:v2.1.0 restart: always command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" depends_on: - plausible_db-testhash - plausible_events_db-testhash ports: - 127.0.0.1:8000:8000 env_file: - plausible-conf.env volumes: db-data-testhash: driver: local event-data-testhash: driver: local event-logs-testhash: driver: local `) as ComposeSpecification; test("Add suffix to all properties in Plausible compose file", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllProperties(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile); }); ================================================ FILE: apps/dokploy/__test__/compose/config/config-root.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); const composeFile = ` version: "3.8" services: web: image: nginx:latest configs: web-config: file: ./web-config.yml `; test("Add suffix to configs in root property", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.configs) { return; } const configs = addSuffixToConfigsRoot(composeData.configs, suffix); expect(configs).toBeDefined(); for (const configKey of Object.keys(configs)) { expect(configKey).toContain(`-${suffix}`); expect(configs[configKey]).toBeDefined(); } }); const composeFileMultipleConfigs = ` version: "3.8" services: web: image: nginx:latest configs: - source: web-config target: /etc/nginx/nginx.conf - source: another-config target: /etc/nginx/another.conf configs: web-config: file: ./web-config.yml another-config: file: ./another-config.yml `; test("Add suffix to multiple configs in root property", () => { const composeData = parse(composeFileMultipleConfigs) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.configs) { return; } const configs = addSuffixToConfigsRoot(composeData.configs, suffix); expect(configs).toBeDefined(); for (const configKey of Object.keys(configs)) { expect(configKey).toContain(`-${suffix}`); expect(configs[configKey]).toBeDefined(); } expect(configs).toHaveProperty(`web-config-${suffix}`); expect(configs).toHaveProperty(`another-config-${suffix}`); }); const composeFileDifferentProperties = ` version: "3.8" services: web: image: nginx:latest configs: web-config: file: ./web-config.yml special-config: external: true `; test("Add suffix to configs with different properties in root property", () => { const composeData = parse( composeFileDifferentProperties, ) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.configs) { return; } const configs = addSuffixToConfigsRoot(composeData.configs, suffix); expect(configs).toBeDefined(); for (const configKey of Object.keys(configs)) { expect(configKey).toContain(`-${suffix}`); expect(configs[configKey]).toBeDefined(); } expect(configs).toHaveProperty(`web-config-${suffix}`); expect(configs).toHaveProperty(`special-config-${suffix}`); }); const composeFileConfigRoot = ` version: "3.8" services: web: image: nginx:latest app: image: node:latest db: image: postgres:latest configs: web_config: file: ./web-config.yml app_config: file: ./app-config.json db_config: file: ./db-config.yml `; // Expected compose file con el prefijo `testhash` const expectedComposeFileConfigRoot = parse(` version: "3.8" services: web: image: nginx:latest app: image: node:latest db: image: postgres:latest configs: web_config-testhash: file: ./web-config.yml app_config-testhash: file: ./app-config.json db_config-testhash: file: ./db-config.yml `) as ComposeSpecification; test("Add suffix to configs in root property", () => { const composeData = parse(composeFileConfigRoot) as ComposeSpecification; const suffix = "testhash"; if (!composeData?.configs) { return; } const configs = addSuffixToConfigsRoot(composeData.configs, suffix); const updatedComposeData = { ...composeData, configs }; // Verificar que el resultado coincide con el archivo esperado expect(updatedComposeData).toEqual(expectedComposeFileConfigRoot); }); ================================================ FILE: apps/dokploy/__test__/compose/config/config-service.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToConfigsInServices, generateRandomHash, } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFile = ` version: "3.8" services: web: image: nginx:latest configs: - source: web-config target: /etc/nginx/nginx.conf configs: web-config: file: ./web-config.yml `; test("Add suffix to configs in services", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.services) { return; } const services = addSuffixToConfigsInServices(composeData.services, suffix); const actualComposeData = { ...composeData, services }; expect(actualComposeData.services?.web?.configs).toContainEqual({ source: `web-config-${suffix}`, target: "/etc/nginx/nginx.conf", }); }); const composeFileSingleServiceConfig = ` version: "3.8" services: web: image: nginx:latest configs: - source: web-config target: /etc/nginx/nginx.conf configs: web-config: file: ./web-config.yml `; test("Add suffix to configs in services with single config", () => { const composeData = parse( composeFileSingleServiceConfig, ) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.services) { return; } const services = addSuffixToConfigsInServices(composeData.services, suffix); expect(services).toBeDefined(); for (const serviceKey of Object.keys(services)) { const serviceConfigs = services?.[serviceKey]?.configs; if (serviceConfigs) { for (const config of serviceConfigs) { if (typeof config === "object") { expect(config.source).toContain(`-${suffix}`); } } } } }); const composeFileMultipleServicesConfigs = ` version: "3.8" services: web: image: nginx:latest configs: - source: web-config target: /etc/nginx/nginx.conf - source: common-config target: /etc/nginx/common.conf app: image: node:14 configs: - source: app-config target: /usr/src/app/config.json - source: common-config target: /usr/src/app/common.json configs: web-config: file: ./web-config.yml app-config: file: ./app-config.json common-config: file: ./common-config.yml `; test("Add suffix to configs in services with multiple configs", () => { const composeData = parse( composeFileMultipleServicesConfigs, ) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.services) { return; } const services = addSuffixToConfigsInServices(composeData.services, suffix); expect(services).toBeDefined(); for (const serviceKey of Object.keys(services)) { const serviceConfigs = services?.[serviceKey]?.configs; if (serviceConfigs) { for (const config of serviceConfigs) { if (typeof config === "object") { expect(config.source).toContain(`-${suffix}`); } } } } }); const composeFileConfigServices = ` version: "3.8" services: web: image: nginx:latest configs: - source: web_config target: /etc/nginx/nginx.conf app: image: node:latest configs: - source: app_config target: /usr/src/app/config.json db: image: postgres:latest configs: - source: db_config target: /etc/postgresql/postgresql.conf `; // Expected compose file con el prefijo `testhash` const expectedComposeFileConfigServices = parse(` version: "3.8" services: web: image: nginx:latest configs: - source: web_config-testhash target: /etc/nginx/nginx.conf app: image: node:latest configs: - source: app_config-testhash target: /usr/src/app/config.json db: image: postgres:latest configs: - source: db_config-testhash target: /etc/postgresql/postgresql.conf `) as ComposeSpecification; test("Add suffix to configs in services", () => { const composeData = parse(composeFileConfigServices) as ComposeSpecification; const suffix = "testhash"; if (!composeData?.services) { return; } const updatedComposeData = addSuffixToConfigsInServices( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; expect(actualComposeData).toEqual(expectedComposeFileConfigServices); }); ================================================ FILE: apps/dokploy/__test__/compose/config/config.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); const composeFileCombinedConfigs = ` version: "3.8" services: web: image: nginx:latest configs: - source: web_config target: /etc/nginx/nginx.conf app: image: node:14 configs: - source: app_config target: /usr/src/app/config.json db: image: postgres:13 configs: - source: db_config target: /etc/postgresql/postgresql.conf configs: web_config: file: ./web-config.yml app_config: file: ./app-config.json db_config: file: ./db-config.yml `; const expectedComposeFileCombinedConfigs = parse(` version: "3.8" services: web: image: nginx:latest configs: - source: web_config-testhash target: /etc/nginx/nginx.conf app: image: node:14 configs: - source: app_config-testhash target: /usr/src/app/config.json db: image: postgres:13 configs: - source: db_config-testhash target: /etc/postgresql/postgresql.conf configs: web_config-testhash: file: ./web-config.yml app_config-testhash: file: ./app-config.json db_config-testhash: file: ./db-config.yml `) as ComposeSpecification; test("Add suffix to all configs in root and services", () => { const composeData = parse(composeFileCombinedConfigs) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllConfigs(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFileCombinedConfigs); }); const composeFileWithEnvAndExternal = ` version: "3.8" services: web: image: nginx:latest configs: - source: web_config target: /etc/nginx/nginx.conf environment: - NGINX_CONFIG=/etc/nginx/nginx.conf app: image: node:14 configs: - source: app_config target: /usr/src/app/config.json db: image: postgres:13 configs: - source: db_config target: /etc/postgresql/postgresql.conf configs: web_config: external: true app_config: file: ./app-config.json db_config: environment: dev file: ./db-config.yml `; const expectedComposeFileWithEnvAndExternal = parse(` version: "3.8" services: web: image: nginx:latest configs: - source: web_config-testhash target: /etc/nginx/nginx.conf environment: - NGINX_CONFIG=/etc/nginx/nginx.conf app: image: node:14 configs: - source: app_config-testhash target: /usr/src/app/config.json db: image: postgres:13 configs: - source: db_config-testhash target: /etc/postgresql/postgresql.conf configs: web_config-testhash: external: true app_config-testhash: file: ./app-config.json db_config-testhash: environment: dev file: ./db-config.yml `) as ComposeSpecification; test("Add suffix to configs with environment and external", () => { const composeData = parse( composeFileWithEnvAndExternal, ) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllConfigs(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFileWithEnvAndExternal); }); const composeFileWithTemplateDriverAndLabels = ` version: "3.8" services: web: image: nginx:latest configs: - source: web_config target: /etc/nginx/nginx.conf app: image: node:14 configs: - source: app_config target: /usr/src/app/config.json configs: web_config: file: ./web-config.yml template_driver: golang app_config: file: ./app-config.json labels: - app=frontend db_config: file: ./db-config.yml `; const expectedComposeFileWithTemplateDriverAndLabels = parse(` version: "3.8" services: web: image: nginx:latest configs: - source: web_config-testhash target: /etc/nginx/nginx.conf app: image: node:14 configs: - source: app_config-testhash target: /usr/src/app/config.json configs: web_config-testhash: file: ./web-config.yml template_driver: golang app_config-testhash: file: ./app-config.json labels: - app=frontend db_config-testhash: file: ./db-config.yml `) as ComposeSpecification; test("Add suffix to configs with template driver and labels", () => { const composeData = parse( composeFileWithTemplateDriverAndLabels, ) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllConfigs(composeData, suffix); expect(updatedComposeData).toEqual( expectedComposeFileWithTemplateDriverAndLabels, ); }); ================================================ FILE: apps/dokploy/__test__/compose/domain/host-rule-format.test.ts ================================================ 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`[^`]+`\)/); }); } }); }); ================================================ FILE: apps/dokploy/__test__/compose/domain/labels.test.ts ================================================ import type { Domain } from "@dokploy/server"; import { createDomainLabels } from "@dokploy/server"; import { describe, expect, it } from "vitest"; describe("createDomainLabels", () => { const appName = "test-app"; 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, }; it("should create basic labels for web entrypoint", async () => { const labels = await createDomainLabels(appName, baseDomain, "web"); expect(labels).toEqual([ "traefik.http.routers.test-app-1-web.rule=Host(`example.com`)", "traefik.http.routers.test-app-1-web.entrypoints=web", "traefik.http.services.test-app-1-web.loadbalancer.server.port=8080", "traefik.http.routers.test-app-1-web.service=test-app-1-web", ]); }); it("should create labels for websecure entrypoint", async () => { const labels = await createDomainLabels(appName, baseDomain, "websecure"); expect(labels).toEqual([ "traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`)", "traefik.http.routers.test-app-1-websecure.entrypoints=websecure", "traefik.http.services.test-app-1-websecure.loadbalancer.server.port=8080", "traefik.http.routers.test-app-1-websecure.service=test-app-1-websecure", ]); }); it("should add the path prefix if is different than / empty", async () => { const labels = await createDomainLabels( appName, { ...baseDomain, path: "/hello", }, "websecure", ); expect(labels).toEqual([ "traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/hello`)", "traefik.http.routers.test-app-1-websecure.entrypoints=websecure", "traefik.http.services.test-app-1-websecure.loadbalancer.server.port=8080", "traefik.http.routers.test-app-1-websecure.service=test-app-1-websecure", ]); }); it("should add redirect middleware for https on web entrypoint", async () => { const httpsBaseDomain = { ...baseDomain, https: true }; const labels = await createDomainLabels(appName, httpsBaseDomain, "web"); expect(labels).toContain( "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", ); }); it("should add Let's Encrypt configuration for websecure with letsencrypt certificate", async () => { const letsencryptDomain = { ...baseDomain, https: true, certificateType: "letsencrypt" as const, }; const labels = await createDomainLabels( appName, letsencryptDomain, "websecure", ); expect(labels).toContain( "traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt", ); }); it("should not add Let's Encrypt configuration for non-letsencrypt certificate", async () => { const nonLetsencryptDomain = { ...baseDomain, https: true, certificateType: "none" as const, }; const labels = await createDomainLabels( appName, nonLetsencryptDomain, "websecure", ); expect(labels).not.toContain( "traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt", ); }); it("should handle different ports correctly", async () => { const customPortDomain = { ...baseDomain, port: 3000 }; const labels = await createDomainLabels(appName, customPortDomain, "web"); expect(labels).toContain( "traefik.http.services.test-app-1-web.loadbalancer.server.port=3000", ); }); it("should add stripPath middleware when stripPath is enabled", async () => { const stripPathDomain = { ...baseDomain, path: "/api", stripPath: true, }; const labels = await createDomainLabels(appName, stripPathDomain, "web"); expect(labels).toContain( "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", ); expect(labels).toContain( "traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1", ); }); it("should add internalPath middleware when internalPath is set", async () => { const internalPathDomain = { ...baseDomain, internalPath: "/hello", }; const webLabels = await createDomainLabels( appName, internalPathDomain, "web", ); const websecureLabels = await createDomainLabels( appName, internalPathDomain, "websecure", ); // Middleware definition should only appear in web entrypoint expect(webLabels).toContain( "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", ); expect(websecureLabels).not.toContain( "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", ); // Both routers should reference the middleware expect(webLabels).toContain( "traefik.http.routers.test-app-1-web.middlewares=addprefix-test-app-1", ); expect(websecureLabels).toContain( "traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1", ); }); it("should combine HTTPS redirect with internalPath middleware in correct order", async () => { const combinedDomain = { ...baseDomain, https: true, internalPath: "/hello", }; const webLabels = await createDomainLabels(appName, combinedDomain, "web"); const websecureLabels = await createDomainLabels( appName, combinedDomain, "websecure", ); // Web entrypoint should have both middlewares with redirect first expect(webLabels).toContain( "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1", ); // Websecure should only have the addprefix middleware expect(websecureLabels).toContain( "traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1", ); // Middleware definition should only appear once (in web) expect(webLabels).toContain( "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", ); expect(websecureLabels).not.toContain( "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", ); }); it("should combine all middlewares in correct order", async () => { const fullDomain = { ...baseDomain, https: true, path: "/api", stripPath: true, internalPath: "/hello", }; const webLabels = await createDomainLabels(appName, fullDomain, "web"); // Should have all middleware definitions (only in web) expect(webLabels).toContain( "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", ); expect(webLabels).toContain( "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", ); // Should have middlewares in correct order: redirect, stripprefix, addprefix expect(webLabels).toContain( "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1", ); }); it("should not add middleware definitions for websecure entrypoint", async () => { const internalPathDomain = { ...baseDomain, path: "/api", stripPath: true, internalPath: "/hello", }; const websecureLabels = await createDomainLabels( appName, internalPathDomain, "websecure", ); // Should not contain any middleware definitions expect(websecureLabels).not.toContain( "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", ); expect(websecureLabels).not.toContain( "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", ); // But should reference the middlewares expect(websecureLabels).toContain( "traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1", ); }); }); ================================================ FILE: apps/dokploy/__test__/compose/domain/network-root.test.ts ================================================ import { addDokployNetworkToRoot } from "@dokploy/server"; import { describe, expect, it } from "vitest"; describe("addDokployNetworkToRoot", () => { it("should create network object if networks is undefined", () => { const result = addDokployNetworkToRoot(undefined); expect(result).toEqual({ "dokploy-network": { external: true } }); }); it("should add network to an empty object", () => { const result = addDokployNetworkToRoot({}); expect(result).toEqual({ "dokploy-network": { external: true } }); }); it("should not modify existing network configuration", () => { const existing = { "dokploy-network": { external: false } }; const result = addDokployNetworkToRoot(existing); expect(result).toEqual({ "dokploy-network": { external: true } }); }); it("should add network alongside existing networks", () => { const existing = { "other-network": { external: true } }; const result = addDokployNetworkToRoot(existing); expect(result).toEqual({ "other-network": { external: true }, "dokploy-network": { external: true }, }); }); }); ================================================ FILE: apps/dokploy/__test__/compose/domain/network-service.test.ts ================================================ import { addDokployNetworkToService } from "@dokploy/server"; import { describe, expect, it } from "vitest"; describe("addDokployNetworkToService", () => { it("should add network to an empty array", () => { const result = addDokployNetworkToService([]); expect(result).toEqual(["dokploy-network", "default"]); }); it("should not add duplicate network to an array", () => { const result = addDokployNetworkToService(["dokploy-network"]); expect(result).toEqual(["dokploy-network", "default"]); }); it("should add network to an existing array with other networks", () => { const result = addDokployNetworkToService(["other-network"]); expect(result).toEqual(["other-network", "dokploy-network", "default"]); }); it("should add network to an object if networks is an object", () => { const result = addDokployNetworkToService({ "other-network": {} }); expect(result).toEqual({ "other-network": {}, "dokploy-network": {}, default: {}, }); }); it("should not duplicate default network when already present", () => { const result = addDokployNetworkToService(["default", "dokploy-network"]); expect(result).toEqual(["default", "dokploy-network"]); }); }); ================================================ FILE: apps/dokploy/__test__/compose/network/network-root.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFile = ` version: "3.8" services: web: image: nginx:latest networks: - frontend networks: frontend: driver: bridge driver_opts: com.docker.network.driver.mtu: 1200 backend: driver: bridge attachable: true external_network: external: true `; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); test("Add suffix to networks root property", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.networks) { return; } const networks = addSuffixToNetworksRoot(composeData.networks, suffix); expect(networks).toBeDefined(); for (const volumeKey of Object.keys(networks)) { expect(volumeKey).toContain(`-${suffix}`); } }); const composeFile2 = ` version: "3.8" services: app: image: myapp:latest networks: - app_net networks: app_net: driver: bridge driver_opts: com.docker.network.driver.mtu: 1500 ipam: driver: default config: - subnet: 172.20.0.0/16 database_net: driver: overlay attachable: true monitoring_net: driver: bridge internal: true `; test("Add suffix to advanced networks root property (2 TRY)", () => { const composeData = parse(composeFile2) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.networks) { return; } const networks = addSuffixToNetworksRoot(composeData.networks, suffix); expect(networks).toBeDefined(); for (const networkKey of Object.keys(networks)) { expect(networkKey).toContain(`-${suffix}`); } }); const composeFile3 = ` version: "3.8" services: web: image: nginx:latest networks: - frontend - backend networks: frontend: external: name: my_external_network backend: driver: bridge labels: - "com.example.description=Backend network" - "com.example.environment=production" external_network: external: true `; test("Add suffix to networks with external properties", () => { const composeData = parse(composeFile3) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.networks) { return; } const networks = addSuffixToNetworksRoot(composeData.networks, suffix); expect(networks).toBeDefined(); for (const networkKey of Object.keys(networks)) { expect(networkKey).toContain(`-${suffix}`); } }); const composeFile4 = ` version: "3.8" services: db: image: postgres:13 networks: - db_net networks: db_net: driver: bridge ipam: config: - subnet: 192.168.1.0/24 - gateway: 192.168.1.1 - aux_addresses: host1: 192.168.1.2 host2: 192.168.1.3 external_network: external: true `; test("Add suffix to networks with IPAM configurations", () => { const composeData = parse(composeFile4) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.networks) { return; } const networks = addSuffixToNetworksRoot(composeData.networks, suffix); expect(networks).toBeDefined(); for (const networkKey of Object.keys(networks)) { expect(networkKey).toContain(`-${suffix}`); } }); const composeFile5 = ` version: "3.8" services: api: image: myapi:latest networks: - api_net networks: api_net: driver: bridge options: com.docker.network.bridge.name: br0 enable_ipv6: true ipam: driver: default config: - subnet: "2001:db8:1::/64" - gateway: "2001:db8:1::1" external_network: external: true `; test("Add suffix to networks with custom options", () => { const composeData = parse(composeFile5) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.networks) { return; } const networks = addSuffixToNetworksRoot(composeData.networks, suffix); expect(networks).toBeDefined(); for (const networkKey of Object.keys(networks)) { expect(networkKey).toContain(`-${suffix}`); } }); const composeFile6 = ` version: "3.8" services: web: image: nginx:latest networks: - frontend networks: frontend: driver: bridge driver_opts: com.docker.network.driver.mtu: 1200 backend: driver: bridge attachable: true external_network: external: true `; // Expected compose file with static suffix `testhash` const expectedComposeFile6 = ` version: "3.8" services: web: image: nginx:latest networks: - frontend-testhash networks: frontend-testhash: driver: bridge driver_opts: com.docker.network.driver.mtu: 1200 backend-testhash: driver: bridge attachable: true external_network-testhash: external: true `; test("Add suffix to networks with static suffix", () => { const composeData = parse(composeFile6) as ComposeSpecification; const suffix = "testhash"; if (!composeData?.networks) { return; } const networks = addSuffixToNetworksRoot(composeData.networks, suffix); const expectedComposeData = parse( expectedComposeFile6, ) as ComposeSpecification; expect(networks).toStrictEqual(expectedComposeData.networks); }); const composeFile7 = ` version: "3.8" services: web: image: nginx:latest networks: - dokploy-network networks: dokploy-network: `; test("It shoudn't add suffix to dokploy-network", () => { const composeData = parse(composeFile7) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.networks) { return; } const networks = addSuffixToNetworksRoot(composeData.networks, suffix); expect(networks).toBeDefined(); for (const networkKey of Object.keys(networks)) { expect(networkKey).toContain("dokploy-network"); } }); ================================================ FILE: apps/dokploy/__test__/compose/network/network-service.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToServiceNetworks, generateRandomHash, } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFile = ` version: "3.8" services: web: image: nginx:latest networks: - frontend - backend api: image: myapi:latest networks: - backend `; test("Add suffix to networks in services", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.services) { return; } const services = addSuffixToServiceNetworks(composeData.services, suffix); const actualComposeData = { ...composeData, services }; expect(actualComposeData?.services?.web?.networks).toContain( `frontend-${suffix}`, ); expect(actualComposeData?.services?.api?.networks).toContain( `backend-${suffix}`, ); const apiNetworks = actualComposeData?.services?.api?.networks; expect(apiNetworks).toBeDefined(); expect(actualComposeData?.services?.api?.networks).toContain( `backend-${suffix}`, ); }); // Caso 2: Objeto con aliases const composeFile2 = ` version: "3.8" services: api: image: myapi:latest networks: frontend: aliases: - api networks: frontend: driver: bridge `; test("Add suffix to networks in services with aliases", () => { const composeData = parse(composeFile2) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.services) { return; } const services = addSuffixToServiceNetworks(composeData.services, suffix); const actualComposeData = { ...composeData, services }; expect(actualComposeData.services?.api?.networks).toHaveProperty( `frontend-${suffix}`, ); const networkConfig = actualComposeData?.services?.api?.networks as { [key: string]: { aliases?: string[] }; }; expect(networkConfig[`frontend-${suffix}`]).toBeDefined(); expect(networkConfig[`frontend-${suffix}`]?.aliases).toContain("api"); expect(actualComposeData.services?.api?.networks).not.toHaveProperty( "frontend-ash", ); }); const composeFile3 = ` version: "3.8" services: redis: image: redis:alpine networks: backend: networks: backend: driver: bridge `; test("Add suffix to networks in services (Object with simple networks)", () => { const composeData = parse(composeFile3) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.services) { return; } const services = addSuffixToServiceNetworks(composeData.services, suffix); const actualComposeData = { ...composeData, services }; expect(actualComposeData.services?.redis?.networks).toHaveProperty( `backend-${suffix}`, ); }); const composeFileCombined = ` version: "3.8" services: web: image: nginx:latest networks: - frontend - backend api: image: myapi:latest networks: frontend: aliases: - api redis: image: redis:alpine networks: backend: networks: frontend: driver: bridge backend: driver: bridge `; test("Add suffix to networks in services (combined case)", () => { const composeData = parse(composeFileCombined) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.services) { return; } const services = addSuffixToServiceNetworks(composeData.services, suffix); const actualComposeData = { ...composeData, services }; // Caso 1: ListOfStrings expect(actualComposeData.services?.web?.networks).toContain( `frontend-${suffix}`, ); expect(actualComposeData.services?.web?.networks).toContain( `backend-${suffix}`, ); // Caso 2: Objeto con aliases const apiNetworks = actualComposeData.services?.api?.networks as { [key: string]: unknown; }; expect(apiNetworks).toHaveProperty(`frontend-${suffix}`); expect(apiNetworks[`frontend-${suffix}`]).toBeDefined(); expect(apiNetworks).not.toHaveProperty("frontend"); // Caso 3: Objeto con redes simples const redisNetworks = actualComposeData.services?.redis?.networks; expect(redisNetworks).toHaveProperty(`backend-${suffix}`); expect(redisNetworks).not.toHaveProperty("backend"); }); const composeFile7 = ` version: "3.8" services: web: image: nginx:latest networks: - dokploy-network `; test("It shoudn't add suffix to dokploy-network in services", () => { const composeData = parse(composeFile7) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.services) { return; } const networks = addSuffixToServiceNetworks(composeData.services, suffix); const service = networks.web; expect(service).toBeDefined(); expect(service?.networks).toContain("dokploy-network"); }); const composeFile8 = ` version: "3.8" services: web: image: nginx:latest networks: - frontend - backend - dokploy-network api: image: myapi:latest networks: frontend: aliases: - api dokploy-network: aliases: - api redis: image: redis:alpine networks: dokploy-network: db: image: myapi:latest networks: dokploy-network: aliases: - apid `; test("It shoudn't add suffix to dokploy-network in services multiples cases", () => { const composeData = parse(composeFile8) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.services) { return; } const networks = addSuffixToServiceNetworks(composeData.services, suffix); const service = networks.web; const api = networks.api; const redis = networks.redis; const db = networks.db; const dbNetworks = db?.networks as { [key: string]: unknown; }; const apiNetworks = api?.networks as { [key: string]: unknown; }; expect(service).toBeDefined(); expect(service?.networks).toContain("dokploy-network"); expect(redis?.networks).toHaveProperty("dokploy-network"); expect(dbNetworks["dokploy-network"]).toBeDefined(); expect(apiNetworks["dokploy-network"]).toBeDefined(); }); ================================================ FILE: apps/dokploy/__test__/compose/network/network.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToAllNetworks, addSuffixToNetworksRoot, addSuffixToServiceNetworks, generateRandomHash, } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFileCombined = ` version: "3.8" services: web: image: nginx:latest networks: - frontend - backend api: image: myapi:latest networks: frontend: aliases: - api redis: image: redis:alpine networks: backend: networks: frontend: driver: bridge backend: driver: bridge `; test("Add suffix to networks in services and root (combined case)", () => { const composeData = parse(composeFileCombined) as ComposeSpecification; const suffix = generateRandomHash(); // Prefijo para redes definidas en el root if (composeData.networks) { composeData.networks = addSuffixToNetworksRoot( composeData.networks, suffix, ); } // Prefijo para redes definidas en los servicios if (composeData.services) { composeData.services = addSuffixToServiceNetworks( composeData.services, suffix, ); } const actualComposeData = { ...composeData }; // Verificar redes en root expect(actualComposeData.networks).toHaveProperty(`frontend-${suffix}`); expect(actualComposeData.networks).toHaveProperty(`backend-${suffix}`); expect(actualComposeData.networks).not.toHaveProperty("frontend"); expect(actualComposeData.networks).not.toHaveProperty("backend"); // Caso 1: ListOfStrings expect(actualComposeData.services?.web?.networks).toContain( `frontend-${suffix}`, ); expect(actualComposeData.services?.web?.networks).toContain( `backend-${suffix}`, ); // Caso 2: Objeto con aliases const apiNetworks = actualComposeData.services?.api?.networks as { [key: string]: { aliases?: string[] }; }; expect(apiNetworks).toHaveProperty(`frontend-${suffix}`); expect(apiNetworks?.[`frontend-${suffix}`]?.aliases).toContain("api"); expect(apiNetworks).not.toHaveProperty("frontend"); // Caso 3: Objeto con redes simples const redisNetworks = actualComposeData.services?.redis?.networks; expect(redisNetworks).toHaveProperty(`backend-${suffix}`); expect(redisNetworks).not.toHaveProperty("backend"); }); const expectedComposeFile = parse(` version: "3.8" services: web: image: nginx:latest networks: - frontend-testhash - backend-testhash api: image: myapi:latest networks: frontend-testhash: aliases: - api redis: image: redis:alpine networks: backend-testhash: networks: frontend-testhash: driver: bridge backend-testhash: driver: bridge `); test("Add suffix to networks in compose file", () => { const composeData = parse(composeFileCombined) as ComposeSpecification; const suffix = "testhash"; if (!composeData?.networks) { return; } const updatedComposeData = addSuffixToAllNetworks(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile); }); const composeFile2 = ` version: "3.8" services: web: image: nginx:latest networks: - frontend - backend db: image: postgres:latest networks: backend: aliases: - db networks: frontend: external: true backend: driver: bridge `; const expectedComposeFile2 = parse(` version: "3.8" services: web: image: nginx:latest networks: - frontend-testhash - backend-testhash db: image: postgres:latest networks: backend-testhash: aliases: - db networks: frontend-testhash: external: true backend-testhash: driver: bridge `); test("Add suffix to networks in compose file with external and internal networks", () => { const composeData = parse(composeFile2) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllNetworks(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile2); }); const composeFile3 = ` version: "3.8" services: app: image: myapp:latest networks: frontend: aliases: - app backend: worker: image: worker:latest networks: - backend networks: frontend: driver: bridge attachable: true backend: driver: bridge driver_opts: com.docker.network.bridge.enable_icc: "true" `; const expectedComposeFile3 = parse(` version: "3.8" services: app: image: myapp:latest networks: frontend-testhash: aliases: - app backend-testhash: worker: image: worker:latest networks: - backend-testhash networks: frontend-testhash: driver: bridge attachable: true backend-testhash: driver: bridge driver_opts: com.docker.network.bridge.enable_icc: "true" `); test("Add suffix to networks in compose file with multiple services and complex network configurations", () => { const composeData = parse(composeFile3) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllNetworks(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile3); }); const composeFile4 = ` version: "3.8" services: app: image: myapp:latest networks: frontend: aliases: - app backend: dokploy-network: worker: image: worker:latest networks: - backend - dokploy-network networks: frontend: driver: bridge attachable: true backend: driver: bridge driver_opts: com.docker.network.bridge.enable_icc: "true" dokploy-network: driver: bridge `; const expectedComposeFile4 = parse(` version: "3.8" services: app: image: myapp:latest networks: frontend-testhash: aliases: - app backend-testhash: dokploy-network: worker: image: worker:latest networks: - backend-testhash - dokploy-network networks: frontend-testhash: driver: bridge attachable: true backend-testhash: driver: bridge driver_opts: com.docker.network.bridge.enable_icc: "true" dokploy-network: driver: bridge `); test("Expect don't add suffix to dokploy-network in compose file with multiple services and complex network configurations", () => { const composeData = parse(composeFile4) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllNetworks(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile4); }); ================================================ FILE: apps/dokploy/__test__/compose/secrets/secret-root.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); const composeFileSecretsRoot = ` version: "3.8" services: web: image: nginx:latest secrets: db_password: file: ./db_password.txt `; test("Add suffix to secrets in root property", () => { const composeData = parse(composeFileSecretsRoot) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.secrets) { return; } const secrets = addSuffixToSecretsRoot(composeData.secrets, suffix); expect(secrets).toBeDefined(); if (secrets) { for (const secretKey of Object.keys(secrets)) { expect(secretKey).toContain(`-${suffix}`); expect(secrets[secretKey]).toBeDefined(); } } }); const composeFileSecretsRoot1 = ` version: "3.8" services: api: image: myapi:latest secrets: api_key: file: ./api_key.txt `; test("Add suffix to secrets in root property (Test 1)", () => { const composeData = parse(composeFileSecretsRoot1) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.secrets) { return; } const secrets = addSuffixToSecretsRoot(composeData.secrets, suffix); expect(secrets).toBeDefined(); if (secrets) { for (const secretKey of Object.keys(secrets)) { expect(secretKey).toContain(`-${suffix}`); expect(secrets[secretKey]).toBeDefined(); } } }); const composeFileSecretsRoot2 = ` version: "3.8" services: frontend: image: nginx:latest secrets: frontend_secret: file: ./frontend_secret.txt db_password: external: true `; test("Add suffix to secrets in root property (Test 2)", () => { const composeData = parse(composeFileSecretsRoot2) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.secrets) { return; } const secrets = addSuffixToSecretsRoot(composeData.secrets, suffix); expect(secrets).toBeDefined(); if (secrets) { for (const secretKey of Object.keys(secrets)) { expect(secretKey).toContain(`-${suffix}`); expect(secrets[secretKey]).toBeDefined(); } } }); ================================================ FILE: apps/dokploy/__test__/compose/secrets/secret-services.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToSecretsInServices, generateRandomHash, } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFileSecretsServices = ` version: "3.8" services: db: image: postgres:latest secrets: - db_password secrets: db_password: file: ./db_password.txt `; test("Add suffix to secrets in services", () => { const composeData = parse(composeFileSecretsServices) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToSecretsInServices( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; expect(actualComposeData.services?.db?.secrets).toContain( `db_password-${suffix}`, ); }); const composeFileSecretsServices1 = ` version: "3.8" services: app: image: node:14 secrets: - app_secret secrets: app_secret: file: ./app_secret.txt `; test("Add suffix to secrets in services (Test 1)", () => { const composeData = parse( composeFileSecretsServices1, ) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToSecretsInServices( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; expect(actualComposeData.services?.app?.secrets).toContain( `app_secret-${suffix}`, ); }); const composeFileSecretsServices2 = ` version: "3.8" services: backend: image: backend:latest secrets: - backend_secret frontend: image: frontend:latest secrets: - frontend_secret secrets: backend_secret: file: ./backend_secret.txt frontend_secret: file: ./frontend_secret.txt `; test("Add suffix to secrets in services (Test 2)", () => { const composeData = parse( composeFileSecretsServices2, ) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToSecretsInServices( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; expect(actualComposeData.services?.backend?.secrets).toContain( `backend_secret-${suffix}`, ); expect(actualComposeData.services?.frontend?.secrets).toContain( `frontend_secret-${suffix}`, ); }); ================================================ FILE: apps/dokploy/__test__/compose/secrets/secret.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToAllSecrets } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFileCombinedSecrets = ` version: "3.8" services: web: image: nginx:latest secrets: - web_secret app: image: node:14 secrets: - app_secret secrets: web_secret: file: ./web_secret.txt app_secret: file: ./app_secret.txt `; const expectedComposeFileCombinedSecrets = parse(` version: "3.8" services: web: image: nginx:latest secrets: - web_secret-testhash app: image: node:14 secrets: - app_secret-testhash secrets: web_secret-testhash: file: ./web_secret.txt app_secret-testhash: file: ./app_secret.txt `) as ComposeSpecification; test("Add suffix to all secrets", () => { const composeData = parse(composeFileCombinedSecrets) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllSecrets(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFileCombinedSecrets); }); const composeFileCombinedSecrets3 = ` version: "3.8" services: api: image: myapi:latest secrets: - api_key cache: image: redis:latest secrets: - cache_secret secrets: api_key: file: ./api_key.txt cache_secret: file: ./cache_secret.txt `; const expectedComposeFileCombinedSecrets3 = parse(` version: "3.8" services: api: image: myapi:latest secrets: - api_key-testhash cache: image: redis:latest secrets: - cache_secret-testhash secrets: api_key-testhash: file: ./api_key.txt cache_secret-testhash: file: ./cache_secret.txt `) as ComposeSpecification; test("Add suffix to all secrets (3rd Case)", () => { const composeData = parse( composeFileCombinedSecrets3, ) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllSecrets(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFileCombinedSecrets3); }); const composeFileCombinedSecrets4 = ` version: "3.8" services: web: image: nginx:latest secrets: - web_secret db: image: postgres:latest secrets: - db_password secrets: web_secret: file: ./web_secret.txt db_password: file: ./db_password.txt `; const expectedComposeFileCombinedSecrets4 = parse(` version: "3.8" services: web: image: nginx:latest secrets: - web_secret-testhash db: image: postgres:latest secrets: - db_password-testhash secrets: web_secret-testhash: file: ./web_secret.txt db_password-testhash: file: ./db_password.txt `) as ComposeSpecification; test("Add suffix to all secrets (4th Case)", () => { const composeData = parse( composeFileCombinedSecrets4, ) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllSecrets(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFileCombinedSecrets4); }); ================================================ FILE: apps/dokploy/__test__/compose/service/service-container-name.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFile = ` version: "3.8" services: web: image: nginx:latest container_name: web_container api: image: myapi:latest networks: default: driver: bridge `; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); test("Add suffix to service names with container_name in compose file", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToServiceNames( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; // Verificar que el nombre del contenedor ha cambiado correctamente expect(actualComposeData.services?.[`web-${suffix}`]?.container_name).toBe( `web_container-${suffix}`, ); // Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe expect(actualComposeData.services).toHaveProperty(`web-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("web"); // Verificar que la configuración de la imagen sigue igual expect(actualComposeData.services?.[`web-${suffix}`]?.image).toBe( "nginx:latest", ); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); }); ================================================ FILE: apps/dokploy/__test__/compose/service/service-depends-on.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); const composeFile4 = ` version: "3.8" services: web: image: nginx:latest depends_on: - db - api api: image: myapi:latest db: image: postgres:latest networks: default: driver: bridge `; test("Add suffix to service names with depends_on (array) in compose file", () => { const composeData = parse(composeFile4) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToServiceNames( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; // Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe expect(actualComposeData.services).toHaveProperty(`web-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("web"); // Verificar que la configuración de la imagen sigue igual expect(actualComposeData.services?.[`web-${suffix}`]?.image).toBe( "nginx:latest", ); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); // Verificar que los nombres en depends_on tienen el prefijo expect(actualComposeData.services?.[`web-${suffix}`]?.depends_on).toContain( `db-${suffix}`, ); expect(actualComposeData.services?.[`web-${suffix}`]?.depends_on).toContain( `api-${suffix}`, ); // Verificar que los servicios `db` y `api` también tienen el prefijo expect(actualComposeData.services).toHaveProperty(`db-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("db"); expect(actualComposeData.services?.[`db-${suffix}`]?.image).toBe( "postgres:latest", ); expect(actualComposeData.services).toHaveProperty(`api-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("api"); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); }); const composeFile5 = ` version: "3.8" services: web: image: nginx:latest depends_on: db: condition: service_healthy api: condition: service_started api: image: myapi:latest db: image: postgres:latest networks: default: driver: bridge `; test("Add suffix to service names with depends_on (object) in compose file", () => { const composeData = parse(composeFile5) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToServiceNames( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; // Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe expect(actualComposeData.services).toHaveProperty(`web-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("web"); // Verificar que la configuración de la imagen sigue igual expect(actualComposeData.services?.[`web-${suffix}`]?.image).toBe( "nginx:latest", ); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); // Verificar que los nombres en depends_on tienen el prefijo const webDependsOn = actualComposeData.services?.[`web-${suffix}`] ?.depends_on as Record; expect(webDependsOn).toHaveProperty(`db-${suffix}`); expect(webDependsOn).toHaveProperty(`api-${suffix}`); expect(webDependsOn[`db-${suffix}`].condition).toBe("service_healthy"); expect(webDependsOn[`api-${suffix}`].condition).toBe("service_started"); // Verificar que los servicios `db` y `api` también tienen el prefijo expect(actualComposeData.services).toHaveProperty(`db-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("db"); expect(actualComposeData.services?.[`db-${suffix}`]?.image).toBe( "postgres:latest", ); expect(actualComposeData.services).toHaveProperty(`api-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("api"); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); }); ================================================ FILE: apps/dokploy/__test__/compose/service/service-extends.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); const composeFile6 = ` version: "3.8" services: web: image: nginx:latest extends: base_service api: image: myapi:latest base_service: image: base:latest networks: default: driver: bridge `; test("Add suffix to service names with extends (string) in compose file", () => { const composeData = parse(composeFile6) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToServiceNames( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; // Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe expect(actualComposeData.services).toHaveProperty(`web-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("web"); // Verificar que la configuración de la imagen sigue igual expect(actualComposeData.services?.[`web-${suffix}`]?.image).toBe( "nginx:latest", ); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); // Verificar que el nombre en extends tiene el prefijo expect(actualComposeData.services?.[`web-${suffix}`]?.extends).toBe( `base_service-${suffix}`, ); // Verificar que el servicio `base_service` también tiene el prefijo expect(actualComposeData.services).toHaveProperty(`base_service-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("base_service"); expect(actualComposeData.services?.[`base_service-${suffix}`]?.image).toBe( "base:latest", ); }); const composeFile7 = ` version: "3.8" services: web: image: nginx:latest extends: service: base_service file: docker-compose.base.yml api: image: myapi:latest base_service: image: base:latest networks: default: driver: bridge `; test("Add suffix to service names with extends (object) in compose file", () => { const composeData = parse(composeFile7) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToServiceNames( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; // Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe expect(actualComposeData.services).toHaveProperty(`web-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("web"); // Verificar que la configuración de la imagen sigue igual expect(actualComposeData.services?.[`web-${suffix}`]?.image).toBe( "nginx:latest", ); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); // Verificar que el nombre en extends.service tiene el prefijo const webExtends = actualComposeData.services?.[`web-${suffix}`]?.extends; if (typeof webExtends !== "string") { expect(webExtends?.service).toBe(`base_service-${suffix}`); } // Verificar que el servicio `base_service` también tiene el prefijo expect(actualComposeData.services).toHaveProperty(`base_service-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("base_service"); expect(actualComposeData.services?.[`base_service-${suffix}`]?.image).toBe( "base:latest", ); }); ================================================ FILE: apps/dokploy/__test__/compose/service/service-links.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); const composeFile2 = ` version: "3.8" services: web: image: nginx:latest links: - db api: image: myapi:latest db: image: postgres:latest networks: default: driver: bridge `; test("Add suffix to service names with links in compose file", () => { const composeData = parse(composeFile2) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToServiceNames( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; // Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe expect(actualComposeData.services).toHaveProperty(`web-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("web"); // Verificar que la configuración de la imagen sigue igual expect(actualComposeData.services?.[`web-${suffix}`]?.image).toBe( "nginx:latest", ); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); // Verificar que los nombres en links tienen el prefijo expect(actualComposeData.services?.[`web-${suffix}`]?.links).toContain( `db-${suffix}`, ); // Verificar que los servicios `db` y `api` también tienen el prefijo expect(actualComposeData.services).toHaveProperty(`db-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("db"); expect(actualComposeData.services?.[`db-${suffix}`]?.image).toBe( "postgres:latest", ); expect(actualComposeData.services).toHaveProperty(`api-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("api"); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); }); ================================================ FILE: apps/dokploy/__test__/compose/service/service-names.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); const composeFile = ` version: "3.8" services: web: image: nginx:latest api: image: myapi:latest networks: default: driver: bridge `; test("Add suffix to service names in compose file", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToServiceNames( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; // Verificar que los nombres de los servicios han cambiado correctamente expect(actualComposeData.services).toHaveProperty(`web-${suffix}`); expect(actualComposeData.services).toHaveProperty(`api-${suffix}`); // Verificar que las claves originales no existen expect(actualComposeData.services).not.toHaveProperty("web"); expect(actualComposeData.services).not.toHaveProperty("api"); }); ================================================ FILE: apps/dokploy/__test__/compose/service/service.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToAllServiceNames, addSuffixToServiceNames, } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFileCombinedAllCases = ` version: "3.8" services: web: image: nginx:latest container_name: web_container links: - api depends_on: - api extends: base_service api: image: myapi:latest depends_on: db: condition: service_healthy volumes_from: - db db: image: postgres:latest base_service: image: base:latest networks: default: driver: bridge `; const expectedComposeFile = parse(` version: "3.8" services: web-testhash: image: nginx:latest container_name: web_container-testhash links: - api-testhash depends_on: - api-testhash extends: base_service-testhash api-testhash: image: myapi:latest depends_on: db-testhash: condition: service_healthy volumes_from: - db-testhash db-testhash: image: postgres:latest base_service-testhash: image: base:latest networks: default: driver: bridge `); test("Add suffix to all service names in compose file", () => { const composeData = parse( composeFileCombinedAllCases, ) as ComposeSpecification; const suffix = "testhash"; if (!composeData.services) { return; } const updatedComposeData = addSuffixToServiceNames( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; expect(actualComposeData).toEqual(expectedComposeFile); }); const composeFile1 = ` version: "3.8" services: web: image: nginx:latest container_name: web_container depends_on: - app networks: - frontend volumes_from: - data links: - db extends: service: base_service app: image: node:14 networks: - backend - frontend db: image: postgres:13 networks: - backend data: image: busybox volumes: - /data base_service: image: base:latest networks: frontend: driver: bridge backend: driver: bridge `; const expectedComposeFile1 = parse(` version: "3.8" services: web-testhash: image: nginx:latest container_name: web_container-testhash depends_on: - app-testhash networks: - frontend volumes_from: - data-testhash links: - db-testhash extends: service: base_service-testhash app-testhash: image: node:14 networks: - backend - frontend db-testhash: image: postgres:13 networks: - backend data-testhash: image: busybox volumes: - /data base_service-testhash: image: base:latest networks: frontend: driver: bridge backend: driver: bridge `) as ComposeSpecification; test("Add suffix to all service names in compose file 1", () => { const composeData = parse(composeFile1) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile1); }); const composeFile2 = ` version: "3.8" services: frontend: image: nginx:latest depends_on: - backend networks: - public volumes_from: - logs links: - cache extends: service: shared_service backend: image: node:14 networks: - private - public cache: image: redis:latest networks: - private logs: image: busybox volumes: - /logs shared_service: image: shared:latest networks: public: driver: bridge private: driver: bridge `; const expectedComposeFile2 = parse(` version: "3.8" services: frontend-testhash: image: nginx:latest depends_on: - backend-testhash networks: - public volumes_from: - logs-testhash links: - cache-testhash extends: service: shared_service-testhash backend-testhash: image: node:14 networks: - private - public cache-testhash: image: redis:latest networks: - private logs-testhash: image: busybox volumes: - /logs shared_service-testhash: image: shared:latest networks: public: driver: bridge private: driver: bridge `) as ComposeSpecification; test("Add suffix to all service names in compose file 2", () => { const composeData = parse(composeFile2) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile2); }); const composeFile3 = ` version: "3.8" services: service_a: image: service_a:latest depends_on: - service_b networks: - net_a volumes_from: - data_volume links: - service_c extends: service: common_service service_b: image: service_b:latest networks: - net_b - net_a service_c: image: service_c:latest networks: - net_b data_volume: image: busybox volumes: - /data common_service: image: common:latest networks: net_a: driver: bridge net_b: driver: bridge `; const expectedComposeFile3 = parse(` version: "3.8" services: service_a-testhash: image: service_a:latest depends_on: - service_b-testhash networks: - net_a volumes_from: - data_volume-testhash links: - service_c-testhash extends: service: common_service-testhash service_b-testhash: image: service_b:latest networks: - net_b - net_a service_c-testhash: image: service_c:latest networks: - net_b data_volume-testhash: image: busybox volumes: - /data common_service-testhash: image: common:latest networks: net_a: driver: bridge net_b: driver: bridge `) as ComposeSpecification; test("Add suffix to all service names in compose file 3", () => { const composeData = parse(composeFile3) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix); expect(updatedComposeData).toEqual(expectedComposeFile3); }); ================================================ FILE: apps/dokploy/__test__/compose/service/sevice-volumes-from.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); const composeFile3 = ` version: "3.8" services: web: image: nginx:latest volumes_from: - shared api: image: myapi:latest volumes_from: - shared shared: image: busybox volumes: - /data networks: default: driver: bridge `; test("Add suffix to service names with volumes_from in compose file", () => { const composeData = parse(composeFile3) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToServiceNames( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; // Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe expect(actualComposeData.services).toHaveProperty(`web-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("web"); // Verificar que la configuración de la imagen sigue igual expect(actualComposeData.services?.[`web-${suffix}`]?.image).toBe( "nginx:latest", ); expect(actualComposeData.services?.[`api-${suffix}`]?.image).toBe( "myapi:latest", ); // Verificar que los nombres en volumes_from tienen el prefijo expect(actualComposeData.services?.[`web-${suffix}`]?.volumes_from).toContain( `shared-${suffix}`, ); expect(actualComposeData.services?.[`api-${suffix}`]?.volumes_from).toContain( `shared-${suffix}`, ); // Verificar que el servicio shared también tiene el prefijo expect(actualComposeData.services).toHaveProperty(`shared-${suffix}`); expect(actualComposeData.services).not.toHaveProperty("shared"); expect(actualComposeData.services?.[`shared-${suffix}`]?.image).toBe( "busybox", ); }); ================================================ FILE: apps/dokploy/__test__/compose/volume/volume-2.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToAllVolumes, addSuffixToVolumesRoot, generateRandomHash, } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFile = ` services: mail: image: bytemark/smtp restart: always plausible_db: image: postgres:14-alpine restart: always volumes: - db-data:/var/lib/postgresql/data environment: - POSTGRES_PASSWORD=postgres plausible_events_db: image: clickhouse/clickhouse-server:23.3.7.5-alpine restart: always volumes: - event-data:/var/lib/clickhouse - event-logs:/var/log/clickhouse-server - ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro - ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro ulimits: nofile: soft: 262144 hard: 262144 plausible: image: plausible/analytics:v2.0 restart: always command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" depends_on: - plausible_db - plausible_events_db - mail ports: - 127.0.0.1:8000:8000 env_file: - plausible-conf.env volumes: - type: volume source: plausible-data target: /data mysql: image: mysql:5.7 restart: always environment: MYSQL_ROOT_PASSWORD: example volumes: - type: volume source: db-data target: /var/lib/mysql/data volumes: db-data: driver: local event-data: driver: local event-logs: driver: local `; const expectedDockerCompose = parse(` services: mail: image: bytemark/smtp restart: always plausible_db: image: postgres:14-alpine restart: always volumes: - db-data-testhash:/var/lib/postgresql/data environment: - POSTGRES_PASSWORD=postgres plausible_events_db: image: clickhouse/clickhouse-server:23.3.7.5-alpine restart: always volumes: - event-data-testhash:/var/lib/clickhouse - event-logs-testhash:/var/log/clickhouse-server - ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro - ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro ulimits: nofile: soft: 262144 hard: 262144 plausible: image: plausible/analytics:v2.0 restart: always command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" depends_on: - plausible_db - plausible_events_db - mail ports: - 127.0.0.1:8000:8000 env_file: - plausible-conf.env volumes: - type: volume source: plausible-data-testhash target: /data mysql: image: mysql:5.7 restart: always environment: MYSQL_ROOT_PASSWORD: example volumes: - type: volume source: db-data-testhash target: /var/lib/mysql/data volumes: db-data-testhash: driver: local event-data-testhash: driver: local event-logs-testhash: driver: local `) as ComposeSpecification; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); // Docker compose needs unique names for services, volumes, networks and containers // So base on a input which is a dockercompose file, it should replace the name with a hash and return a new dockercompose file test("Add suffix to volumes root property", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.volumes) { return; } const volumes = addSuffixToVolumesRoot(composeData.volumes, suffix); // { // 'db-data-af045046': { driver: 'local' }, // 'event-data-af045046': { driver: 'local' }, // 'event-logs-af045046': { driver: 'local' } // } expect(volumes).toBeDefined(); for (const volumeKey of Object.keys(volumes)) { expect(volumeKey).toContain(`-${suffix}`); } }); test("Expect to change the suffix in all the possible places", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); expect(updatedComposeData).toEqual(expectedDockerCompose); }); const composeFile2 = ` version: '3.8' services: app: image: myapp:latest build: context: . dockerfile: Dockerfile volumes: - app-config:/usr/src/app/config - ./config:/usr/src/app/config:ro environment: - NODE_ENV=production mongo: image: mongo:4.2 volumes: - mongo-data:/data/db volumes: app-config: mongo-data: `; const expectedDockerCompose2 = parse(` version: '3.8' services: app: image: myapp:latest build: context: . dockerfile: Dockerfile volumes: - app-config-testhash:/usr/src/app/config - ./config:/usr/src/app/config:ro environment: - NODE_ENV=production mongo: image: mongo:4.2 volumes: - mongo-data-testhash:/data/db volumes: app-config-testhash: mongo-data-testhash: `) as ComposeSpecification; test("Expect to change the suffix in all the possible places (2 Try)", () => { const composeData = parse(composeFile2) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); expect(updatedComposeData).toEqual(expectedDockerCompose2); }); const composeFile3 = ` version: '3.8' services: app: image: myapp:latest build: context: . dockerfile: Dockerfile volumes: - app-config:/usr/src/app/config - ./config:/usr/src/app/config:ro environment: - NODE_ENV=production mongo: image: mongo:4.2 volumes: - mongo-data:/data/db volumes: app-config: mongo-data: `; const expectedDockerCompose3 = parse(` version: '3.8' services: app: image: myapp:latest build: context: . dockerfile: Dockerfile volumes: - app-config-testhash:/usr/src/app/config - ./config:/usr/src/app/config:ro environment: - NODE_ENV=production mongo: image: mongo:4.2 volumes: - mongo-data-testhash:/data/db volumes: app-config-testhash: mongo-data-testhash: `) as ComposeSpecification; test("Expect to change the suffix in all the possible places (3 Try)", () => { const composeData = parse(composeFile3) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); expect(updatedComposeData).toEqual(expectedDockerCompose3); }); const composeFileComplex = ` version: "3.8" services: studio: container_name: supabase-studio image: supabase/studio:20240422-5cf8f30 restart: unless-stopped healthcheck: test: [ "CMD", "node", "-e", "require('http').get('http://localhost:3000/api/profile', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" ] timeout: 5s interval: 5s retries: 3 depends_on: analytics: condition: service_healthy environment: STUDIO_PG_META_URL: http://meta:8080 POSTGRES_PASSWORD: \${POSTGRES_PASSWORD} DEFAULT_ORGANIZATION_NAME: \${STUDIO_DEFAULT_ORGANIZATION} DEFAULT_PROJECT_NAME: \${STUDIO_DEFAULT_PROJECT} SUPABASE_URL: http://kong:8000 SUPABASE_PUBLIC_URL: \${SUPABASE_PUBLIC_URL} SUPABASE_ANON_KEY: \${ANON_KEY} SUPABASE_SERVICE_KEY: \${SERVICE_ROLE_KEY} LOGFLARE_API_KEY: \${LOGFLARE_API_KEY} LOGFLARE_URL: http://analytics:4000 NEXT_PUBLIC_ENABLE_LOGS: true NEXT_ANALYTICS_BACKEND_PROVIDER: postgres kong: container_name: supabase-kong image: kong:2.8.1 restart: unless-stopped entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' ports: - \${KONG_HTTP_PORT}:8000/tcp - \${KONG_HTTPS_PORT}:8443/tcp depends_on: analytics: condition: service_healthy environment: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml KONG_DNS_ORDER: LAST,A,CNAME KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k SUPABASE_ANON_KEY: \${ANON_KEY} SUPABASE_SERVICE_KEY: \${SERVICE_ROLE_KEY} DASHBOARD_USERNAME: \${DASHBOARD_USERNAME} DASHBOARD_PASSWORD: \${DASHBOARD_PASSWORD} volumes: - ./volumes/api/kong.yml:/home/kong/temp.yml:ro auth: container_name: supabase-auth image: supabase/gotrue:v2.151.0 depends_on: db: condition: service_healthy analytics: condition: service_healthy healthcheck: test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health" ] timeout: 5s interval: 5s retries: 3 restart: unless-stopped environment: GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_PORT: 9999 API_EXTERNAL_URL: \${API_EXTERNAL_URL} GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} GOTRUE_SITE_URL: \${SITE_URL} GOTRUE_URI_ALLOW_LIST: \${ADDITIONAL_REDIRECT_URLS} GOTRUE_DISABLE_SIGNUP: \${DISABLE_SIGNUP} GOTRUE_JWT_ADMIN_ROLES: service_role GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_EXP: \${JWT_EXPIRY} GOTRUE_JWT_SECRET: \${JWT_SECRET} GOTRUE_EXTERNAL_EMAIL_ENABLED: \${ENABLE_EMAIL_SIGNUP} GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: \${ENABLE_ANONYMOUS_USERS} GOTRUE_MAILER_AUTOCONFIRM: \${ENABLE_EMAIL_AUTOCONFIRM} GOTRUE_SMTP_ADMIN_EMAIL: \${SMTP_ADMIN_EMAIL} GOTRUE_SMTP_HOST: \${SMTP_HOST} GOTRUE_SMTP_PORT: \${SMTP_PORT} GOTRUE_SMTP_USER: \${SMTP_USER} GOTRUE_SMTP_PASS: \${SMTP_PASS} GOTRUE_SMTP_SENDER_NAME: \${SMTP_SENDER_NAME} GOTRUE_MAILER_URLPATHS_INVITE: \${MAILER_URLPATHS_INVITE} GOTRUE_MAILER_URLPATHS_CONFIRMATION: \${MAILER_URLPATHS_CONFIRMATION} GOTRUE_MAILER_URLPATHS_RECOVERY: \${MAILER_URLPATHS_RECOVERY} GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: \${MAILER_URLPATHS_EMAIL_CHANGE} GOTRUE_EXTERNAL_PHONE_ENABLED: \${ENABLE_PHONE_SIGNUP} GOTRUE_SMS_AUTOCONFIRM: \${ENABLE_PHONE_AUTOCONFIRM} rest: container_name: supabase-rest image: postgrest/postgrest:v12.0.1 depends_on: db: condition: service_healthy analytics: condition: service_healthy restart: unless-stopped environment: PGRST_DB_URI: postgres://authenticator:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} PGRST_DB_SCHEMAS: \${PGRST_DB_SCHEMAS} PGRST_DB_ANON_ROLE: anon PGRST_JWT_SECRET: \${JWT_SECRET} PGRST_DB_USE_LEGACY_GUCS: "false" PGRST_APP_SETTINGS_JWT_SECRET: \${JWT_SECRET} PGRST_APP_SETTINGS_JWT_EXP: \${JWT_EXPIRY} command: "postgrest" realtime: container_name: realtime-dev.supabase-realtime image: supabase/realtime:v2.28.32 depends_on: db: condition: service_healthy analytics: condition: service_healthy healthcheck: test: [ "CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "-H", "Authorization: Bearer \${ANON_KEY}", "http://localhost:4000/api/tenants/realtime-dev/health" ] timeout: 5s interval: 5s retries: 3 restart: unless-stopped environment: PORT: 4000 DB_HOST: \${POSTGRES_HOST} DB_PORT: \${POSTGRES_PORT} DB_USER: supabase_admin DB_PASSWORD: \${POSTGRES_PASSWORD} DB_NAME: \${POSTGRES_DB} DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' DB_ENC_KEY: supabaserealtime API_JWT_SECRET: \${JWT_SECRET} FLY_ALLOC_ID: fly123 FLY_APP_NAME: realtime SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq ERL_AFLAGS: -proto_dist inet_tcp ENABLE_TAILSCALE: "false" DNS_NODES: "''" command: > sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server" storage: container_name: supabase-storage image: supabase/storage-api:v1.0.6 depends_on: db: condition: service_healthy rest: condition: service_started imgproxy: condition: service_started healthcheck: test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status" ] timeout: 5s interval: 5s retries: 3 restart: unless-stopped environment: ANON_KEY: \${ANON_KEY} SERVICE_KEY: \${SERVICE_ROLE_KEY} POSTGREST_URL: http://rest:3000 PGRST_JWT_SECRET: \${JWT_SECRET} DATABASE_URL: postgres://supabase_storage_admin:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /var/lib/storage TENANT_ID: stub REGION: stub GLOBAL_S3_BUCKET: stub ENABLE_IMAGE_TRANSFORMATION: "true" IMGPROXY_URL: http://imgproxy:5001 volumes: - ./volumes/storage:/var/lib/storage:z imgproxy: container_name: supabase-imgproxy image: darthsim/imgproxy:v3.8.0 healthcheck: test: [ "CMD", "imgproxy", "health" ] timeout: 5s interval: 5s retries: 3 environment: IMGPROXY_BIND: ":5001" IMGPROXY_LOCAL_FILESYSTEM_ROOT: / IMGPROXY_USE_ETAG: "true" IMGPROXY_ENABLE_WEBP_DETECTION: \${IMGPROXY_ENABLE_WEBP_DETECTION} volumes: - ./volumes/storage:/var/lib/storage:z meta: container_name: supabase-meta image: supabase/postgres-meta:v0.80.0 depends_on: db: condition: service_healthy analytics: condition: service_healthy restart: unless-stopped environment: PG_META_PORT: 8080 PG_META_DB_HOST: \${POSTGRES_HOST} PG_META_DB_PORT: \${POSTGRES_PORT} PG_META_DB_NAME: \${POSTGRES_DB} PG_META_DB_USER: supabase_admin PG_META_DB_PASSWORD: \${POSTGRES_PASSWORD} functions: container_name: supabase-edge-functions image: supabase/edge-runtime:v1.45.2 restart: unless-stopped depends_on: analytics: condition: service_healthy environment: JWT_SECRET: \${JWT_SECRET} SUPABASE_URL: http://kong:8000 SUPABASE_ANON_KEY: \${ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY} SUPABASE_DB_URL: postgresql://postgres:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} VERIFY_JWT: "\${FUNCTIONS_VERIFY_JWT}" volumes: - ./volumes/functions:/home/deno/functions:Z command: - start - --main-service - /home/deno/functions/main analytics: container_name: supabase-analytics image: supabase/logflare:1.4.0 healthcheck: test: [ "CMD", "curl", "http://localhost:4000/health" ] timeout: 5s interval: 5s retries: 10 restart: unless-stopped depends_on: db: condition: service_healthy environment: LOGFLARE_NODE_HOST: 127.0.0.1 DB_USERNAME: supabase_admin DB_DATABASE: \${POSTGRES_DB} DB_HOSTNAME: \${POSTGRES_HOST} DB_PORT: \${POSTGRES_PORT} DB_PASSWORD: \${POSTGRES_PASSWORD} DB_SCHEMA: _analytics LOGFLARE_API_KEY: \${LOGFLARE_API_KEY} LOGFLARE_SINGLE_TENANT: true LOGFLARE_SUPABASE_MODE: true LOGFLARE_MIN_CLUSTER_SIZE: 1 POSTGRES_BACKEND_URL: postgresql://supabase_admin:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} POSTGRES_BACKEND_SCHEMA: _analytics LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true ports: - 4000:4000 db: container_name: supabase-db image: supabase/postgres:15.1.1.41 healthcheck: test: pg_isready -U postgres -h localhost interval: 5s timeout: 5s retries: 10 depends_on: vector: condition: service_healthy command: - postgres - -c - config_file=/etc/postgresql/postgresql.conf - -c - log_min_messages=fatal restart: unless-stopped ports: - \${POSTGRES_PORT}:\${POSTGRES_PORT} environment: POSTGRES_HOST: /var/run/postgresql PGPORT: \${POSTGRES_PORT} POSTGRES_PORT: \${POSTGRES_PORT} PGPASSWORD: \${POSTGRES_PASSWORD} POSTGRES_PASSWORD: \${POSTGRES_PASSWORD} PGDATABASE: \${POSTGRES_DB} POSTGRES_DB: \${POSTGRES_DB} JWT_SECRET: \${JWT_SECRET} JWT_EXP: \${JWT_EXPIRY} volumes: - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z - ./volumes/db/data:/var/lib/postgresql/data:Z - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z - db-config:/etc/postgresql-custom vector: container_name: supabase-vector image: timberio/vector:0.28.1-alpine healthcheck: test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://vector:9001/health" ] timeout: 5s interval: 5s retries: 3 volumes: - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro - "\${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro" environment: LOGFLARE_API_KEY: \${LOGFLARE_API_KEY} command: [ "--config", "etc/vector/vector.yml" ] volumes: db-config: `; const expectedDockerComposeComplex = parse(` version: "3.8" services: studio: container_name: supabase-studio image: supabase/studio:20240422-5cf8f30 restart: unless-stopped healthcheck: test: [ "CMD", "node", "-e", "require('http').get('http://localhost:3000/api/profile', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" ] timeout: 5s interval: 5s retries: 3 depends_on: analytics: condition: service_healthy environment: STUDIO_PG_META_URL: http://meta:8080 POSTGRES_PASSWORD: \${POSTGRES_PASSWORD} DEFAULT_ORGANIZATION_NAME: \${STUDIO_DEFAULT_ORGANIZATION} DEFAULT_PROJECT_NAME: \${STUDIO_DEFAULT_PROJECT} SUPABASE_URL: http://kong:8000 SUPABASE_PUBLIC_URL: \${SUPABASE_PUBLIC_URL} SUPABASE_ANON_KEY: \${ANON_KEY} SUPABASE_SERVICE_KEY: \${SERVICE_ROLE_KEY} LOGFLARE_API_KEY: \${LOGFLARE_API_KEY} LOGFLARE_URL: http://analytics:4000 NEXT_PUBLIC_ENABLE_LOGS: true NEXT_ANALYTICS_BACKEND_PROVIDER: postgres kong: container_name: supabase-kong image: kong:2.8.1 restart: unless-stopped entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' ports: - \${KONG_HTTP_PORT}:8000/tcp - \${KONG_HTTPS_PORT}:8443/tcp depends_on: analytics: condition: service_healthy environment: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml KONG_DNS_ORDER: LAST,A,CNAME KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k SUPABASE_ANON_KEY: \${ANON_KEY} SUPABASE_SERVICE_KEY: \${SERVICE_ROLE_KEY} DASHBOARD_USERNAME: \${DASHBOARD_USERNAME} DASHBOARD_PASSWORD: \${DASHBOARD_PASSWORD} volumes: - ./volumes/api/kong.yml:/home/kong/temp.yml:ro auth: container_name: supabase-auth image: supabase/gotrue:v2.151.0 depends_on: db: condition: service_healthy analytics: condition: service_healthy healthcheck: test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health" ] timeout: 5s interval: 5s retries: 3 restart: unless-stopped environment: GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_PORT: 9999 API_EXTERNAL_URL: \${API_EXTERNAL_URL} GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} GOTRUE_SITE_URL: \${SITE_URL} GOTRUE_URI_ALLOW_LIST: \${ADDITIONAL_REDIRECT_URLS} GOTRUE_DISABLE_SIGNUP: \${DISABLE_SIGNUP} GOTRUE_JWT_ADMIN_ROLES: service_role GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_EXP: \${JWT_EXPIRY} GOTRUE_JWT_SECRET: \${JWT_SECRET} GOTRUE_EXTERNAL_EMAIL_ENABLED: \${ENABLE_EMAIL_SIGNUP} GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: \${ENABLE_ANONYMOUS_USERS} GOTRUE_MAILER_AUTOCONFIRM: \${ENABLE_EMAIL_AUTOCONFIRM} GOTRUE_SMTP_ADMIN_EMAIL: \${SMTP_ADMIN_EMAIL} GOTRUE_SMTP_HOST: \${SMTP_HOST} GOTRUE_SMTP_PORT: \${SMTP_PORT} GOTRUE_SMTP_USER: \${SMTP_USER} GOTRUE_SMTP_PASS: \${SMTP_PASS} GOTRUE_SMTP_SENDER_NAME: \${SMTP_SENDER_NAME} GOTRUE_MAILER_URLPATHS_INVITE: \${MAILER_URLPATHS_INVITE} GOTRUE_MAILER_URLPATHS_CONFIRMATION: \${MAILER_URLPATHS_CONFIRMATION} GOTRUE_MAILER_URLPATHS_RECOVERY: \${MAILER_URLPATHS_RECOVERY} GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: \${MAILER_URLPATHS_EMAIL_CHANGE} GOTRUE_EXTERNAL_PHONE_ENABLED: \${ENABLE_PHONE_SIGNUP} GOTRUE_SMS_AUTOCONFIRM: \${ENABLE_PHONE_AUTOCONFIRM} rest: container_name: supabase-rest image: postgrest/postgrest:v12.0.1 depends_on: db: condition: service_healthy analytics: condition: service_healthy restart: unless-stopped environment: PGRST_DB_URI: postgres://authenticator:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} PGRST_DB_SCHEMAS: \${PGRST_DB_SCHEMAS} PGRST_DB_ANON_ROLE: anon PGRST_JWT_SECRET: \${JWT_SECRET} PGRST_DB_USE_LEGACY_GUCS: "false" PGRST_APP_SETTINGS_JWT_SECRET: \${JWT_SECRET} PGRST_APP_SETTINGS_JWT_EXP: \${JWT_EXPIRY} command: "postgrest" realtime: container_name: realtime-dev.supabase-realtime image: supabase/realtime:v2.28.32 depends_on: db: condition: service_healthy analytics: condition: service_healthy healthcheck: test: [ "CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "-H", "Authorization: Bearer \${ANON_KEY}", "http://localhost:4000/api/tenants/realtime-dev/health" ] timeout: 5s interval: 5s retries: 3 restart: unless-stopped environment: PORT: 4000 DB_HOST: \${POSTGRES_HOST} DB_PORT: \${POSTGRES_PORT} DB_USER: supabase_admin DB_PASSWORD: \${POSTGRES_PASSWORD} DB_NAME: \${POSTGRES_DB} DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' DB_ENC_KEY: supabaserealtime API_JWT_SECRET: \${JWT_SECRET} FLY_ALLOC_ID: fly123 FLY_APP_NAME: realtime SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq ERL_AFLAGS: -proto_dist inet_tcp ENABLE_TAILSCALE: "false" DNS_NODES: "''" command: > sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server" storage: container_name: supabase-storage image: supabase/storage-api:v1.0.6 depends_on: db: condition: service_healthy rest: condition: service_started imgproxy: condition: service_started healthcheck: test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status" ] timeout: 5s interval: 5s retries: 3 restart: unless-stopped environment: ANON_KEY: \${ANON_KEY} SERVICE_KEY: \${SERVICE_ROLE_KEY} POSTGREST_URL: http://rest:3000 PGRST_JWT_SECRET: \${JWT_SECRET} DATABASE_URL: postgres://supabase_storage_admin:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /var/lib/storage TENANT_ID: stub REGION: stub GLOBAL_S3_BUCKET: stub ENABLE_IMAGE_TRANSFORMATION: "true" IMGPROXY_URL: http://imgproxy:5001 volumes: - ./volumes/storage:/var/lib/storage:z imgproxy: container_name: supabase-imgproxy image: darthsim/imgproxy:v3.8.0 healthcheck: test: [ "CMD", "imgproxy", "health" ] timeout: 5s interval: 5s retries: 3 environment: IMGPROXY_BIND: ":5001" IMGPROXY_LOCAL_FILESYSTEM_ROOT: / IMGPROXY_USE_ETAG: "true" IMGPROXY_ENABLE_WEBP_DETECTION: \${IMGPROXY_ENABLE_WEBP_DETECTION} volumes: - ./volumes/storage:/var/lib/storage:z meta: container_name: supabase-meta image: supabase/postgres-meta:v0.80.0 depends_on: db: condition: service_healthy analytics: condition: service_healthy restart: unless-stopped environment: PG_META_PORT: 8080 PG_META_DB_HOST: \${POSTGRES_HOST} PG_META_DB_PORT: \${POSTGRES_PORT} PG_META_DB_NAME: \${POSTGRES_DB} PG_META_DB_USER: supabase_admin PG_META_DB_PASSWORD: \${POSTGRES_PASSWORD} functions: container_name: supabase-edge-functions image: supabase/edge-runtime:v1.45.2 restart: unless-stopped depends_on: analytics: condition: service_healthy environment: JWT_SECRET: \${JWT_SECRET} SUPABASE_URL: http://kong:8000 SUPABASE_ANON_KEY: \${ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY} SUPABASE_DB_URL: postgresql://postgres:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} VERIFY_JWT: "\${FUNCTIONS_VERIFY_JWT}" volumes: - ./volumes/functions:/home/deno/functions:Z command: - start - --main-service - /home/deno/functions/main analytics: container_name: supabase-analytics image: supabase/logflare:1.4.0 healthcheck: test: [ "CMD", "curl", "http://localhost:4000/health" ] timeout: 5s interval: 5s retries: 10 restart: unless-stopped depends_on: db: condition: service_healthy environment: LOGFLARE_NODE_HOST: 127.0.0.1 DB_USERNAME: supabase_admin DB_DATABASE: \${POSTGRES_DB} DB_HOSTNAME: \${POSTGRES_HOST} DB_PORT: \${POSTGRES_PORT} DB_PASSWORD: \${POSTGRES_PASSWORD} DB_SCHEMA: _analytics LOGFLARE_API_KEY: \${LOGFLARE_API_KEY} LOGFLARE_SINGLE_TENANT: true LOGFLARE_SUPABASE_MODE: true LOGFLARE_MIN_CLUSTER_SIZE: 1 POSTGRES_BACKEND_URL: postgresql://supabase_admin:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} POSTGRES_BACKEND_SCHEMA: _analytics LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true ports: - 4000:4000 db: container_name: supabase-db image: supabase/postgres:15.1.1.41 healthcheck: test: pg_isready -U postgres -h localhost interval: 5s timeout: 5s retries: 10 depends_on: vector: condition: service_healthy command: - postgres - -c - config_file=/etc/postgresql/postgresql.conf - -c - log_min_messages=fatal restart: unless-stopped ports: - \${POSTGRES_PORT}:\${POSTGRES_PORT} environment: POSTGRES_HOST: /var/run/postgresql PGPORT: \${POSTGRES_PORT} POSTGRES_PORT: \${POSTGRES_PORT} PGPASSWORD: \${POSTGRES_PASSWORD} POSTGRES_PASSWORD: \${POSTGRES_PASSWORD} PGDATABASE: \${POSTGRES_DB} POSTGRES_DB: \${POSTGRES_DB} JWT_SECRET: \${JWT_SECRET} JWT_EXP: \${JWT_EXPIRY} volumes: - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z - ./volumes/db/data:/var/lib/postgresql/data:Z - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z - db-config-testhash:/etc/postgresql-custom vector: container_name: supabase-vector image: timberio/vector:0.28.1-alpine healthcheck: test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://vector:9001/health" ] timeout: 5s interval: 5s retries: 3 volumes: - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro - \${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro environment: LOGFLARE_API_KEY: \${LOGFLARE_API_KEY} command: [ "--config", "etc/vector/vector.yml" ] volumes: db-config-testhash: `); test("Expect to change the suffix in all the possible places (4 Try)", () => { const composeData = parse(composeFileComplex) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); expect(updatedComposeData).toEqual(expectedDockerComposeComplex); }); const composeFileExample1 = ` version: "3.8" services: web: image: nginx:latest ports: - "80:80" networks: - frontend volumes: - web-data:/var/www/html - ./nginx.conf:/etc/nginx/nginx.conf:ro app: image: node:14 depends_on: - db networks: - backend - frontend volumes: - app-data:/usr/src/app - ./src:/usr/src/app/src db: image: postgres:13 environment: POSTGRES_PASSWORD: example networks: - backend volumes: - db-data:/var/lib/postgresql/data networks: frontend: driver: bridge backend: driver: bridge volumes: web-data: app-data: db-data: `; const expectedDockerComposeExample1 = parse(` version: "3.8" services: web: image: nginx:latest ports: - "80:80" networks: - frontend volumes: - web-data-testhash:/var/www/html - ./nginx.conf:/etc/nginx/nginx.conf:ro app: image: node:14 depends_on: - db networks: - backend - frontend volumes: - app-data-testhash:/usr/src/app - ./src:/usr/src/app/src db: image: postgres:13 environment: POSTGRES_PASSWORD: example networks: - backend volumes: - db-data-testhash:/var/lib/postgresql/data networks: frontend: driver: bridge backend: driver: bridge volumes: web-data-testhash: app-data-testhash: db-data-testhash: `) as ComposeSpecification; test("Expect to change the suffix in all the possible places (5 Try)", () => { const composeData = parse(composeFileExample1) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); expect(updatedComposeData).toEqual(expectedDockerComposeExample1); }); const composeFileBackrest = ` services: backrest: image: garethgeorge/backrest:v1.7.3 restart: unless-stopped ports: - 9898 environment: - BACKREST_PORT=9898 - BACKREST_DATA=/data - BACKREST_CONFIG=/config/config.json - XDG_CACHE_HOME=/cache - TZ=\${TZ} volumes: - backrest/data:/data - backrest/config:/config - backrest/cache:/cache - /:/userdata:ro volumes: backrest: backrest-cache: `; const expectedDockerComposeBackrest = parse(` services: backrest: image: garethgeorge/backrest:v1.7.3 restart: unless-stopped ports: - 9898 environment: - BACKREST_PORT=9898 - BACKREST_DATA=/data - BACKREST_CONFIG=/config/config.json - XDG_CACHE_HOME=/cache - TZ=\${TZ} volumes: - backrest-testhash/data:/data - backrest-testhash/config:/config - backrest-testhash/cache:/cache - /:/userdata:ro volumes: backrest-testhash: backrest-cache-testhash: `) as ComposeSpecification; test("Should handle volume paths with subdirectories correctly", () => { const composeData = parse(composeFileBackrest) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); expect(updatedComposeData).toEqual(expectedDockerComposeBackrest); }); ================================================ FILE: apps/dokploy/__test__/compose/volume/volume-root.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFile = ` version: "3.8" services: web: image: nginx:latest volumes: - web_data:/var/lib/nginx/data volumes: web_data: driver: local networks: default: driver: bridge `; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); test("Add suffix to volumes in root property", () => { const composeData = parse(composeFile) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.volumes) { return; } const volumes = addSuffixToVolumesRoot(composeData.volumes, suffix); expect(volumes).toBeDefined(); for (const volumeKey of Object.keys(volumes)) { expect(volumeKey).toContain(`-${suffix}`); expect(volumes[volumeKey]).toBeDefined(); } }); const composeFile2 = ` version: "3.8" services: app: image: node:latest volumes: - app_data:/var/lib/app/data volumes: app_data: driver: local driver_opts: type: nfs o: addr=10.0.0.1,rw device: ":/exported/path" networks: default: driver: bridge `; test("Add suffix to volumes in root property (Case 2)", () => { const composeData = parse(composeFile2) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.volumes) { return; } const volumes = addSuffixToVolumesRoot(composeData.volumes, suffix); expect(volumes).toBeDefined(); for (const volumeKey of Object.keys(volumes)) { expect(volumeKey).toContain(`-${suffix}`); expect(volumes[volumeKey]).toBeDefined(); } }); const composeFile3 = ` version: "3.8" services: db: image: postgres:latest volumes: - db_data:/var/lib/postgresql/data volumes: db_data: external: true networks: default: driver: bridge `; test("Add suffix to volumes in root property (Case 3)", () => { const composeData = parse(composeFile3) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData?.volumes) { return; } const volumes = addSuffixToVolumesRoot(composeData.volumes, suffix); expect(volumes).toBeDefined(); for (const volumeKey of Object.keys(volumes)) { expect(volumeKey).toContain(`-${suffix}`); expect(volumes[volumeKey]).toBeDefined(); } }); const composeFile4 = ` version: "3.8" services: web: image: nginx:latest app: image: node:latest db: image: postgres:latest volumes: web_data: driver: local app_data: driver: local driver_opts: type: nfs o: addr=10.0.0.1,rw device: ":/exported/path" db_data: external: true `; // Expected compose file con el prefijo `testhash` const expectedComposeFile4 = parse(` version: "3.8" services: web: image: nginx:latest app: image: node:latest db: image: postgres:latest volumes: web_data-testhash: driver: local app_data-testhash: driver: local driver_opts: type: nfs o: addr=10.0.0.1,rw device: ":/exported/path" db_data-testhash: external: true `) as ComposeSpecification; test("Add suffix to volumes in root property", () => { const composeData = parse(composeFile4) as ComposeSpecification; const suffix = "testhash"; if (!composeData?.volumes) { return; } const volumes = addSuffixToVolumesRoot(composeData.volumes, suffix); const updatedComposeData = { ...composeData, volumes }; // Verificar que el resultado coincide con el archivo esperado expect(updatedComposeData).toEqual(expectedComposeFile4); }); ================================================ FILE: apps/dokploy/__test__/compose/volume/volume-services.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToVolumesInServices, generateRandomHash, } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; test("Generate random hash with 8 characters", () => { const hash = generateRandomHash(); expect(hash).toBeDefined(); expect(hash.length).toBe(8); }); const composeFile1 = ` version: "3.8" services: db: image: postgres:latest volumes: - db_data:/var/lib/postgresql/data `; test("Add suffix to volumes declared directly in services", () => { const composeData = parse(composeFile1) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToVolumesInServices( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; expect(actualComposeData.services?.db?.volumes).toContain( `db_data-${suffix}:/var/lib/postgresql/data`, ); }); const composeFileTypeVolume = ` version: "3.8" services: db: image: postgres:latest volumes: - type: volume source: db-test target: /var/lib/postgresql/data volumes: db-test: driver: local `; test("Add suffix to volumes declared directly in services (Case 2)", () => { const composeData = parse(composeFileTypeVolume) as ComposeSpecification; const suffix = generateRandomHash(); if (!composeData.services) { return; } const updatedComposeData = addSuffixToVolumesInServices( composeData.services, suffix, ); const actualComposeData = { ...composeData, services: updatedComposeData }; expect(actualComposeData.services?.db?.volumes).toEqual([ { type: "volume", source: `db-test-${suffix}`, target: "/var/lib/postgresql/data", }, ]); }); ================================================ FILE: apps/dokploy/__test__/compose/volume/volume.test.ts ================================================ import type { ComposeSpecification } from "@dokploy/server"; import { addSuffixToAllVolumes } from "@dokploy/server"; import { expect, test } from "vitest"; import { parse } from "yaml"; const composeFileTypeVolume = ` version: "3.8" services: db1: image: postgres:latest volumes: - "db-test:/var/lib/postgresql/data" db2: image: postgres:latest volumes: - type: volume source: db-test target: /var/lib/postgresql/data volumes: db-test: driver: local `; const expectedComposeFileTypeVolume = parse(` version: "3.8" services: db1: image: postgres:latest volumes: - "db-test-testhash:/var/lib/postgresql/data" db2: image: postgres:latest volumes: - type: volume source: db-test-testhash target: /var/lib/postgresql/data volumes: db-test-testhash: driver: local `) as ComposeSpecification; test("Add suffix to volumes with type: volume in services", () => { const composeData = parse(composeFileTypeVolume) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const actualComposeData = { ...composeData, ...updatedComposeData }; expect(actualComposeData).toEqual(expectedComposeFileTypeVolume); }); const composeFileTypeVolume1 = ` version: "3.8" services: web: image: nginx:latest volumes: - "web-data:/var/www/html" - type: volume source: web-logs target: /var/log/nginx volumes: web-data: driver: local web-logs: driver: local `; const expectedComposeFileTypeVolume1 = parse(` version: "3.8" services: web: image: nginx:latest volumes: - "web-data-testhash:/var/www/html" - type: volume source: web-logs-testhash target: /var/log/nginx volumes: web-data-testhash: driver: local web-logs-testhash: driver: local `) as ComposeSpecification; test("Add suffix to mixed volumes in services", () => { const composeData = parse(composeFileTypeVolume1) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const actualComposeData = { ...composeData, ...updatedComposeData }; expect(actualComposeData).toEqual(expectedComposeFileTypeVolume1); }); const composeFileTypeVolume2 = ` version: "3.8" services: app: image: node:latest volumes: - "app-data:/usr/src/app" - type: volume source: app-logs target: /var/log/app volume: nocopy: true volumes: app-data: driver: local app-logs: driver: local driver_opts: o: bind type: none device: /path/to/app/logs `; const expectedComposeFileTypeVolume2 = parse(` version: "3.8" services: app: image: node:latest volumes: - "app-data-testhash:/usr/src/app" - type: volume source: app-logs-testhash target: /var/log/app volume: nocopy: true volumes: app-data-testhash: driver: local app-logs-testhash: driver: local driver_opts: o: bind type: none device: /path/to/app/logs `) as ComposeSpecification; test("Add suffix to complex volume configurations in services", () => { const composeData = parse(composeFileTypeVolume2) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const actualComposeData = { ...composeData, ...updatedComposeData }; expect(actualComposeData).toEqual(expectedComposeFileTypeVolume2); }); const composeFileTypeVolume3 = ` version: "3.8" services: web: image: nginx:latest volumes: - "web-data:/usr/share/nginx/html" - type: volume source: web-logs target: /var/log/nginx volume: nocopy: true api: image: node:latest volumes: - "api-data:/usr/src/app" - type: volume source: api-logs target: /var/log/app volume: nocopy: true - type: volume source: shared-logs target: /shared/logs volumes: web-data: driver: local web-logs: driver: local driver_opts: o: bind type: none device: /path/to/web/logs api-data: driver: local api-logs: driver: local driver_opts: o: bind type: none device: /path/to/api/logs shared-logs: driver: local driver_opts: o: bind type: none device: /path/to/shared/logs `; const expectedComposeFileTypeVolume3 = parse(` version: "3.8" services: web: image: nginx:latest volumes: - "web-data-testhash:/usr/share/nginx/html" - type: volume source: web-logs-testhash target: /var/log/nginx volume: nocopy: true api: image: node:latest volumes: - "api-data-testhash:/usr/src/app" - type: volume source: api-logs-testhash target: /var/log/app volume: nocopy: true - type: volume source: shared-logs-testhash target: /shared/logs volumes: web-data-testhash: driver: local web-logs-testhash: driver: local driver_opts: o: bind type: none device: /path/to/web/logs api-data-testhash: driver: local api-logs-testhash: driver: local driver_opts: o: bind type: none device: /path/to/api/logs shared-logs-testhash: driver: local driver_opts: o: bind type: none device: /path/to/shared/logs `) as ComposeSpecification; test("Add suffix to complex nested volumes configuration in services", () => { const composeData = parse(composeFileTypeVolume3) as ComposeSpecification; const suffix = "testhash"; const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const actualComposeData = { ...composeData, ...updatedComposeData }; expect(actualComposeData).toEqual(expectedComposeFileTypeVolume3); }); ================================================ FILE: apps/dokploy/__test__/deploy/application.command.test.ts ================================================ 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), from: vi.fn(() => chain), innerJoin: vi.fn(() => chain), then: (resolve: (v: any) => void) => { resolve([]); }, } as any; return chain; }; return { db: { select: vi.fn(() => createChainableMock()), insert: vi.fn(), update: vi.fn(() => createChainableMock()), delete: vi.fn(), query: { applications: { findFirst: vi.fn(), }, patch: { findMany: vi.fn().mockResolvedValue([]), }, member: { findMany: vi.fn().mockResolvedValue([]), }, }, }, }; }); 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"); }); }); ================================================ FILE: apps/dokploy/__test__/deploy/application.real.test.ts ================================================ 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([{}]), from: vi.fn(() => chain), innerJoin: vi.fn(() => chain), then: (resolve: (v: any) => void) => { resolve([]); }, }; return chain; }; return { db: { select: vi.fn(() => createChainableMock()), insert: vi.fn(), update: vi.fn(() => createChainableMock()), delete: vi.fn(), query: { applications: { findFirst: vi.fn(), }, patch: { findMany: vi.fn().mockResolvedValue([]), }, member: { findMany: vi.fn().mockResolvedValue([]), }, }, }, }; }); 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, ); ================================================ FILE: apps/dokploy/__test__/deploy/github.test.ts ================================================ import { describe, expect, it } from "vitest"; import { extractCommitMessage, extractImageName, extractImageTag, extractImageTagFromRequest, } from "@/pages/api/deploy/[refreshToken]"; describe("GitHub Webhook Skip CI", () => { const mockGithubHeaders = { "x-github-event": "push", }; const createMockBody = (message: string) => ({ head_commit: { message, }, }); const skipKeywords = [ "[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]", ]; it("should detect skip keywords in commit message", () => { for (const keyword of skipKeywords) { const message = `feat: add new feature ${keyword}`; const commitMessage = extractCommitMessage( mockGithubHeaders, createMockBody(message), ); expect(commitMessage.includes(keyword)).toBe(true); } }); it("should not detect skip keywords in normal commit message", () => { const message = "feat: add new feature"; const commitMessage = extractCommitMessage( mockGithubHeaders, createMockBody(message), ); for (const keyword of skipKeywords) { expect(commitMessage.includes(keyword)).toBe(false); } }); it("should handle different webhook sources", () => { // GitHub expect( extractCommitMessage( { "x-github-event": "push" }, { head_commit: { message: "[skip ci] test" } }, ), ).toBe("[skip ci] test"); // GitLab expect( extractCommitMessage( { "x-gitlab-event": "push" }, { commits: [{ message: "[skip ci] test" }] }, ), ).toBe("[skip ci] test"); // Bitbucket expect( extractCommitMessage( { "x-event-key": "repo:push" }, { push: { changes: [{ new: { target: { message: "[skip ci] test" } } }], }, }, ), ).toBe("[skip ci] test"); // Gitea expect( extractCommitMessage( { "x-gitea-event": "push" }, { commits: [{ message: "[skip ci] test" }] }, ), ).toBe("[skip ci] test"); // Soft Serve expect( extractCommitMessage( { "x-softserve-event": "push" }, { commits: [{ message: "[skip ci] test" }] }, ), ).toBe("[skip ci] test"); }); it("should handle missing commit message", () => { expect(extractCommitMessage(mockGithubHeaders, {})).toBe("NEW COMMIT"); expect(extractCommitMessage({ "x-gitlab-event": "push" }, {})).toBe( "NEW COMMIT", ); expect( extractCommitMessage( { "x-event-key": "repo:push" }, { push: { changes: [] } }, ), ).toBe("NEW COMMIT"); expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe( "NEW COMMIT", ); expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe( "NEW COMMIT", ); }); }); 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"); }); }); }); ================================================ FILE: apps/dokploy/__test__/deploy/soft-serve.test.ts ================================================ import { describe, expect, it } from "vitest"; import { extractBranchName, extractCommitMessage, extractHash, getProviderByHeader, } from "@/pages/api/deploy/[refreshToken]"; describe("Soft Serve Webhook", () => { const mockSoftServeHeaders = { "x-softserve-event": "push", }; const createMockBody = (message: string, hash: string, branch: string) => ({ event: "push", ref: `refs/heads/${branch}`, after: hash, commits: [{ message: message }], }); const message: string = "feat: add new feature"; const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5"; const branch: string = "feat/add-new"; const goodWebhook = createMockBody(message, hash, branch); it("should properly extract the provider name", () => { expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve"); }); it("should properly extract the commit message", () => { expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe( message, ); }); it("should properly extract hash", () => { expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash); }); it("should properly extract branch name", () => { expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch); }); it("should gracefully handle invalid webhook", () => { expect(getProviderByHeader({})).toBeNull(); expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT"); expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT"); expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull(); }); }); ================================================ FILE: apps/dokploy/__test__/drop/drop.test.ts ================================================ import fs from "node:fs/promises"; import path from "node:path"; import type { ApplicationNested } from "@dokploy/server"; import { unzipDrop } from "@dokploy/server"; import { paths } from "@dokploy/server/constants"; import AdmZip from "adm-zip"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; const OUTPUT_BASE = "./__test__/drop/zips/output"; const { APPLICATIONS_PATH } = paths(); vi.mock("@dokploy/server/constants", async (importOriginal) => { const actual = await importOriginal(); return { // @ts-ignore ...actual, paths: () => ({ // @ts-ignore ...actual.paths(), BASE_PATH: OUTPUT_BASE, APPLICATIONS_PATH: OUTPUT_BASE, }), }; }); if (typeof window === "undefined") { const undici = require("undici"); globalThis.File = undici.File as any; globalThis.FileList = undici.FileList as any; } const baseApp: ApplicationNested = { railpackVersion: "0.15.4", applicationId: "", previewLabels: [], createEnvFile: true, bitbucketRepositorySlug: "", herokuVersion: "", giteaBranch: "", buildServerId: "", buildRegistryId: "", buildRegistry: null, args: [], giteaBuildPath: "", previewRequireCollaboratorPermissions: false, giteaId: "", giteaOwner: "", giteaRepository: "", cleanCache: false, watchPaths: [], rollbackRegistryId: "", rollbackRegistry: null, deployments: [], enableSubmodules: false, applicationStatus: "done", triggerType: "push", appName: "", autoDeploy: true, endpointSpecSwarm: null, serverId: "", registryUrl: "", branch: null, dockerBuildStage: "", isPreviewDeploymentsActive: false, previewBuildArgs: null, previewBuildSecrets: null, previewCertificateType: "none", previewCustomCertResolver: null, previewEnv: null, previewHttps: false, previewPath: "/", previewPort: 3000, previewLimit: 0, previewWildcard: "", environment: { env: "", isDefault: false, environmentId: "", name: "", createdAt: "", description: "", projectId: "", project: { env: "", organizationId: "", name: "", description: "", createdAt: "", projectId: "", }, }, buildArgs: null, buildSecrets: null, buildPath: "/", gitlabPathNamespace: "", buildType: "nixpacks", bitbucketBranch: "", bitbucketBuildPath: "", bitbucketId: "", bitbucketRepository: "", bitbucketOwner: "", githubId: "", gitlabProjectId: 0, gitlabBranch: "", gitlabBuildPath: "", gitlabId: "", gitlabRepository: "", gitlabOwner: "", command: null, cpuLimit: null, cpuReservation: null, createdAt: "", customGitBranch: "", customGitBuildPath: "", customGitSSHKeyId: null, customGitUrl: "", description: "", dockerfile: null, dockerImage: null, dropBuildPath: null, environmentId: "", enabled: null, env: null, healthCheckSwarm: null, labelsSwarm: null, memoryLimit: null, memoryReservation: null, modeSwarm: null, mounts: [], name: "", networkSwarm: null, owner: null, password: null, placementSwarm: null, ports: [], publishDirectory: null, isStaticSpa: null, redirects: [], refreshToken: "", registry: null, registryId: null, replicas: 1, repository: null, restartPolicySwarm: null, rollbackConfigSwarm: null, security: [], sourceType: "git", subtitle: null, title: null, updateConfigSwarm: null, username: null, dockerContextPath: null, rollbackActive: false, stopGracePeriodSwarm: null, ulimitsSwarm: null, }; /** * GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal. * Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron * plus cover files (package.json, index.js). unzipDrop must reject and never write outside output. */ describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => { beforeAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); afterAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => { baseApp.appName = "ghsa-rce"; // PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace) const traversalEntry = "../../../../../etc/cron.d/malicious-cron"; const cronPayload = "* * * * * root id\n"; const placeholder = "x".repeat(traversalEntry.length); const zip = new AdmZip(); zip.addFile( "package.json", Buffer.from('{"name": "app", "version": "1.0.0"}'), ); zip.addFile("index.js", Buffer.from('console.log("Application");')); zip.addFile(placeholder, Buffer.from(cronPayload)); let buf = Buffer.from(zip.toBuffer()); buf = Buffer.from( buf.toString("binary").split(placeholder).join(traversalEntry), "binary", ); const file = new File([buf as unknown as ArrayBuffer], "exploit.zip"); await expect(unzipDrop(file, baseApp)).rejects.toThrow( /Path traversal detected.*resolved path escapes output directory/, ); }); }); describe("security: existing symlink escape", () => { beforeAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); afterAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); it("should NOT write outside base when directory is a symlink", async () => { const appName = "symlink-existing"; const output = path.join(APPLICATIONS_PATH, appName, "code"); await fs.mkdir(output, { recursive: true }); // outside target (attacker wants to write here) const outside = path.join(APPLICATIONS_PATH, "..", "outside"); await fs.mkdir(outside, { recursive: true }); // attacker-controlled symlink inside project await fs.symlink(outside, path.join(output, "logs")); // zip looks totally harmless const zip = new AdmZip(); zip.addFile("logs/pwned.txt", Buffer.from("owned")); const file = new File([zip.toBuffer() as any], "exploit.zip"); await unzipDrop(file, { ...baseApp, appName }); // if vulnerable -> file exists outside sandbox const escaped = await fs .readFile(path.join(outside, "pwned.txt"), "utf8") .then(() => true) .catch(() => false); expect(escaped).toBe(false); }); }); describe("security: zip symlink entry blocked", () => { beforeAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); afterAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); it("rejects zip containing real symlink entry", async () => { const appName = "zip-symlink"; const zipBuffer = await fs.readFile( path.join(__dirname, "./zips/payload/symlink-entry.zip"), ); const file = new File([zipBuffer as any], "exploit.zip"); await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow( /Dangerous node entries are not allowed/, ); }); }); describe("unzipDrop path under output (no traversal)", () => { beforeAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); afterAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => { baseApp.appName = "cron-under-output"; const zip = new AdmZip(); zip.addFile( "etc/cron.d/malicious-cron", Buffer.from("* * * * * root id\n"), ); zip.addFile("package.json", Buffer.from('{"name":"app"}')); const file = new File( [zip.toBuffer() as unknown as ArrayBuffer], "app.zip", ); const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); await unzipDrop(file, baseApp); const content = await fs.readFile( path.join(outputPath, "etc/cron.d/malicious-cron"), "utf8", ); expect(content).toBe("* * * * * root id\n"); }); }); describe("security: traversal inside BASE_PATH (sandbox escape)", () => { beforeAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); afterAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); it("should NOT allow writing outside application directory but inside BASE_PATH", async () => { const appName = "sandbox-escape"; const base = APPLICATIONS_PATH.replace("/applications", ""); const output = path.join(APPLICATIONS_PATH, appName, "code"); await fs.mkdir(output, { recursive: true }); // attacker writes into traefik config inside base const zip = new AdmZip(); zip.addFile( "../../../traefik/dynamic/evil.yml", Buffer.from("pwned: true"), ); const file = new File([zip.toBuffer() as any], "exploit.zip"); await unzipDrop(file, { ...baseApp, appName }); const escapedPath = path.join(base, "traefik/dynamic/evil.yml"); const exists = await fs .readFile(escapedPath) .then(() => true) .catch(() => false); expect(exists).toBe(false); }); }); describe("unzipDrop using real zip files", () => { // const { APPLICATIONS_PATH } = paths(); beforeAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); afterAll(async () => { await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); }); it("should correctly extract a zip with a single root folder", async () => { baseApp.appName = "single-file"; // const appName = "single-file"; try { const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); const zip = new AdmZip("./__test__/drop/zips/single-file.zip"); const zipBuffer = zip.toBuffer() as Buffer; const file = new File([zipBuffer], "single.zip"); await unzipDrop(file, baseApp); const files = await fs.readdir(outputPath, { withFileTypes: true }); expect(files.some((f) => f.name === "test.txt")).toBe(true); } catch (err) { } finally { } }); }); // it("should correctly extract a zip with a single root folder and a subfolder", async () => { // baseApp.appName = "folderwithfile"; // // const appName = "folderwithfile"; // const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); // const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip"); // const zipBuffer = zip.toBuffer(); // const file = new File([zipBuffer], "single.zip"); // await unzipDrop(file, baseApp); // const files = await fs.readdir(outputPath, { withFileTypes: true }); // expect(files.some((f) => f.name === "folder1.txt")).toBe(true); // }); // it("should correctly extract a zip with multiple root folders", async () => { // baseApp.appName = "two-folders"; // // const appName = "two-folders"; // const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); // const zip = new AdmZip("./__test__/drop/zips/two-folders.zip"); // const zipBuffer = zip.toBuffer(); // const file = new File([zipBuffer], "single.zip"); // await unzipDrop(file, baseApp); // const files = await fs.readdir(outputPath, { withFileTypes: true }); // expect(files.some((f) => f.name === "folder1")).toBe(true); // expect(files.some((f) => f.name === "folder2")).toBe(true); // }); // it("should correctly extract a zip with a single root with a file", async () => { // baseApp.appName = "nested"; // // const appName = "nested"; // const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); // const zip = new AdmZip("./__test__/drop/zips/nested.zip"); // const zipBuffer = zip.toBuffer(); // const file = new File([zipBuffer], "single.zip"); // await unzipDrop(file, baseApp); // const files = await fs.readdir(outputPath, { withFileTypes: true }); // expect(files.some((f) => f.name === "folder1")).toBe(true); // expect(files.some((f) => f.name === "folder2")).toBe(true); // expect(files.some((f) => f.name === "folder3")).toBe(true); // }); // it("should correctly extract a zip with a single root with a folder", async () => { // baseApp.appName = "folder-with-sibling-file"; // // const appName = "folder-with-sibling-file"; // const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); // const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip"); // const zipBuffer = zip.toBuffer(); // const file = new File([zipBuffer], "single.zip"); // await unzipDrop(file, baseApp); // const files = await fs.readdir(outputPath, { withFileTypes: true }); // expect(files.some((f) => f.name === "folder1")).toBe(true); // expect(files.some((f) => f.name === "test.txt")).toBe(true); // }); // }); ================================================ FILE: apps/dokploy/__test__/drop/zips/folder1/folder1.txt ================================================ Gogogogogogo ================================================ FILE: apps/dokploy/__test__/drop/zips/folder2/folder2.txt ================================================ gogogogogog ================================================ FILE: apps/dokploy/__test__/drop/zips/folder3/file3.txt ================================================ gogogogogogogogogo ================================================ FILE: apps/dokploy/__test__/drop/zips/test.txt ================================================ dsafasdfasdf ================================================ FILE: apps/dokploy/__test__/env/environment-access-fallback.test.ts ================================================ 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(); }); }); }); ================================================ FILE: apps/dokploy/__test__/env/environment.test.ts ================================================ import { prepareEnvironmentVariables, prepareEnvironmentVariablesForShell, } 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("prepareEnvironmentVariables (environment variables)", () => { it("resolves environment variables correctly", () => { const serviceWithEnvVars = ` NODE_ENV=\${{environment.NODE_ENV}} API_URL=\${{environment.API_URL}} SERVICE_PORT=4000 `; const resolved = prepareEnvironmentVariables( serviceWithEnvVars, "", environmentEnv, ); expect(resolved).toEqual([ "NODE_ENV=development", "API_URL=https://api.dev.example.com", "SERVICE_PORT=4000", ]); }); it("resolves both project and environment variables", () => { const serviceWithBoth = ` ENVIRONMENT=\${{project.ENVIRONMENT}} NODE_ENV=\${{environment.NODE_ENV}} API_URL=\${{environment.API_URL}} DATABASE_URL=\${{project.DATABASE_URL}} SERVICE_PORT=4000 `; const resolved = prepareEnvironmentVariables( serviceWithBoth, projectEnv, environmentEnv, ); expect(resolved).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 undefined environment variables", () => { const serviceWithUndefined = ` UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}} `; expect(() => prepareEnvironmentVariables(serviceWithUndefined, "", environmentEnv), ).toThrow("Invalid environment variable: environment.UNDEFINED_VAR"); }); it("allows service variables to override environment variables", () => { const serviceOverrideEnv = ` NODE_ENV=production API_URL=\${{environment.API_URL}} `; const resolved = prepareEnvironmentVariables( serviceOverrideEnv, "", environmentEnv, ); expect(resolved).toEqual([ "NODE_ENV=production", // Overrides environment variable "API_URL=https://api.dev.example.com", ]); }); it("resolves complex references with project, environment, and service variables", () => { 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 resolved = prepareEnvironmentVariables( complexServiceEnv, projectEnv, environmentEnv, ); expect(resolved).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("handles environment variables with special characters", () => { const specialEnvVars = ` SPECIAL_URL=https://special.com COMPLEX_KEY="key-with-@#$%^&*()" JWT_SECRET="secret-with-spaces and symbols!@#" `; const serviceWithSpecial = ` FULL_URL=\${{environment.SPECIAL_URL}}/path?key=\${{environment.COMPLEX_KEY}} AUTH_SECRET=\${{environment.JWT_SECRET}} `; const resolved = prepareEnvironmentVariables( serviceWithSpecial, "", specialEnvVars, ); expect(resolved).toEqual([ "FULL_URL=https://special.com/path?key=key-with-@#$%^&*()", "AUTH_SECRET=secret-with-spaces and symbols!@#", ]); }); it("maintains precedence: service > environment > project", () => { 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 resolved = prepareEnvironmentVariables( serviceWithConflicts, conflictingProjectEnv, conflictingEnvironmentEnv, ); expect(resolved).toEqual([ "NODE_ENV=service-override", // Service wins "PROJECT_ENV=production-project", // Project reference "ENV_VAR=https://environment.api.com", // Environment reference "DB_NAME=env_db", // Environment reference ]); }); it("handles empty environment variables", () => { const serviceWithEmpty = ` SERVICE_VAR=test PROJECT_VAR=\${{project.ENVIRONMENT}} `; const resolved = prepareEnvironmentVariables( serviceWithEmpty, projectEnv, "", ); expect(resolved).toEqual(["SERVICE_VAR=test", "PROJECT_VAR=staging"]); }); it("handles mixed quotes and environment variables", () => { const envWithQuotes = ` QUOTED_VAR="development" SINGLE_QUOTED='https://api.dev.example.com' MIXED_VAR="value with 'single' quotes" `; const serviceWithQuotes = ` NODE_ENV=\${{environment.QUOTED_VAR}} API_URL=\${{environment.SINGLE_QUOTED}} COMPLEX="Prefix-\${{environment.MIXED_VAR}}-Suffix" `; const resolved = prepareEnvironmentVariables( serviceWithQuotes, "", envWithQuotes, ); expect(resolved).toEqual([ "NODE_ENV=development", "API_URL=https://api.dev.example.com", "COMPLEX=Prefix-value with 'single' quotes-Suffix", ]); }); it("resolves multiple environment references in single value", () => { const multiRefEnv = ` HOST=localhost PORT=5432 USERNAME=postgres PASSWORD=secret123 `; const serviceWithMultiRefs = ` DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb CONNECTION_STRING=\${{environment.HOST}}:\${{environment.PORT}} `; const resolved = prepareEnvironmentVariables( serviceWithMultiRefs, "", multiRefEnv, ); expect(resolved).toEqual([ "DATABASE_URL=postgresql://postgres:secret123@localhost:5432/mydb", "CONNECTION_STRING=localhost:5432", ]); }); it("handles nested references with environment and project variables", () => { const nestedProjectEnv = ` BASE_DOMAIN=example.com PROTOCOL=https `; const nestedEnvironmentEnv = ` SUBDOMAIN=api.dev PATH_PREFIX=/v1 `; const serviceWithNested = ` FULL_URL=\${{project.PROTOCOL}}://\${{environment.SUBDOMAIN}}.\${{project.BASE_DOMAIN}}\${{environment.PATH_PREFIX}}/endpoint API_BASE=\${{project.PROTOCOL}}://\${{environment.SUBDOMAIN}}.\${{project.BASE_DOMAIN}} `; const resolved = prepareEnvironmentVariables( serviceWithNested, nestedProjectEnv, nestedEnvironmentEnv, ); expect(resolved).toEqual([ "FULL_URL=https://api.dev.example.com/v1/endpoint", "API_BASE=https://api.dev.example.com", ]); }); it("throws error for malformed environment variable references", () => { const serviceWithMalformed = ` MALFORMED1=\${{environment.}} MALFORMED2=\${{environment}} VALID=\${{environment.NODE_ENV}} `; // Should throw error for empty variable name after environment. expect(() => prepareEnvironmentVariables(serviceWithMalformed, "", environmentEnv), ).toThrow("Invalid environment variable: environment."); }); it("handles environment variables with numeric values", () => { const numericEnv = ` PORT=8080 TIMEOUT=30 RETRY_COUNT=3 PERCENTAGE=99.5 `; const serviceWithNumeric = ` SERVER_PORT=\${{environment.PORT}} REQUEST_TIMEOUT=\${{environment.TIMEOUT}} MAX_RETRIES=\${{environment.RETRY_COUNT}} SUCCESS_RATE=\${{environment.PERCENTAGE}} `; const resolved = prepareEnvironmentVariables( serviceWithNumeric, "", numericEnv, ); expect(resolved).toEqual([ "SERVER_PORT=8080", "REQUEST_TIMEOUT=30", "MAX_RETRIES=3", "SUCCESS_RATE=99.5", ]); }); it("handles boolean-like environment variables", () => { const booleanEnv = ` DEBUG=true ENABLED=false PRODUCTION=1 DEVELOPMENT=0 `; const serviceWithBoolean = ` DEBUG_MODE=\${{environment.DEBUG}} FEATURE_ENABLED=\${{environment.ENABLED}} IS_PROD=\${{environment.PRODUCTION}} IS_DEV=\${{environment.DEVELOPMENT}} `; const resolved = prepareEnvironmentVariables( serviceWithBoolean, "", booleanEnv, ); expect(resolved).toEqual([ "DEBUG_MODE=true", "FEATURE_ENABLED=false", "IS_PROD=1", "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é"); }); }); ================================================ FILE: apps/dokploy/__test__/env/shared.test.ts ================================================ import { prepareEnvironmentVariables } 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 serviceEnv = ` ENVIRONMENT=\${{project.ENVIRONMENT}} DATABASE_URL=\${{project.DATABASE_URL}} SERVICE_PORT=4000 `; describe("prepareEnvironmentVariables", () => { it("resolves project variables correctly", () => { const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv); expect(resolved).toEqual([ "ENVIRONMENT=staging", "DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db", "SERVICE_PORT=4000", ]); }); it("handles undefined project variables", () => { const incompleteProjectEnv = ` NODE_ENV=production `; const invalidServiceEnv = ` UNDEFINED_VAR=\${{project.UNDEFINED_VAR}} `; expect( () => prepareEnvironmentVariables(invalidServiceEnv, incompleteProjectEnv), // Cambiado el orden ).toThrow("Invalid project environment variable: project.UNDEFINED_VAR"); }); it("allows service-specific variables to override project variables", () => { const serviceSpecificEnv = ` ENVIRONMENT=production DATABASE_URL=\${{project.DATABASE_URL}} `; const resolved = prepareEnvironmentVariables( serviceSpecificEnv, projectEnv, ); expect(resolved).toEqual([ "ENVIRONMENT=production", // Overrides project variable "DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db", ]); }); it("resolves complex references for dynamic endpoints", () => { const projectEnv = ` BASE_URL=https://api.example.com API_VERSION=v1 PORT=8000 `; const serviceEnv = ` API_ENDPOINT=\${{project.BASE_URL}}/\${{project.API_VERSION}}/endpoint SERVICE_PORT=9000 `; const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv); expect(resolved).toEqual([ "API_ENDPOINT=https://api.example.com/v1/endpoint", "SERVICE_PORT=9000", ]); }); it("handles missing project variables gracefully", () => { const projectEnv = ` PORT=8080 `; const serviceEnv = ` MISSING_VAR=\${{project.MISSING_KEY}} SERVICE_PORT=3000 `; expect(() => prepareEnvironmentVariables(serviceEnv, projectEnv)).toThrow( "Invalid project environment variable: project.MISSING_KEY", ); }); it("overrides project variables with service-specific values", () => { const projectEnv = ` ENVIRONMENT=staging DATABASE_URL=postgres://project:project@localhost:5432/project_db `; const serviceEnv = ` ENVIRONMENT=\${{project.ENVIRONMENT}} DATABASE_URL=postgres://service:service@localhost:5432/service_db SERVICE_NAME=my-service `; const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv); expect(resolved).toEqual([ "ENVIRONMENT=staging", "DATABASE_URL=postgres://service:service@localhost:5432/service_db", "SERVICE_NAME=my-service", ]); }); it("handles project variables with normal and unusual characters", () => { const projectEnv = ` ENVIRONMENT=PRODUCTION `; // Needs to be in quotes const serviceEnv = ` NODE_ENV=\${{project.ENVIRONMENT}} SPECIAL_VAR="$^@$^@#$^@!#$@#$-\${{project.ENVIRONMENT}}" `; const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv); expect(resolved).toEqual([ "NODE_ENV=PRODUCTION", "SPECIAL_VAR=$^@$^@#$^@!#$@#$-PRODUCTION", ]); }); it("handles complex cases with multiple references, special characters, and spaces", () => { const projectEnv = ` ENVIRONMENT=STAGING APP_NAME=MyApp `; const serviceEnv = ` NODE_ENV=\${{project.ENVIRONMENT}} COMPLEX_VAR="Prefix-$#^!@-\${{project.ENVIRONMENT}}--\${{project.APP_NAME}} Suffix " `; const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv); expect(resolved).toEqual([ "NODE_ENV=STAGING", "COMPLEX_VAR=Prefix-$#^!@-STAGING--MyApp Suffix ", ]); }); it("handles references enclosed in single quotes", () => { const projectEnv = ` ENVIRONMENT=STAGING APP_NAME=MyApp `; const serviceEnv = ` NODE_ENV='\${{project.ENVIRONMENT}}' COMPLEX_VAR='Prefix-$#^!@-\${{project.ENVIRONMENT}}--\${{project.APP_NAME}} Suffix' `; const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv); expect(resolved).toEqual([ "NODE_ENV=STAGING", "COMPLEX_VAR=Prefix-$#^!@-STAGING--MyApp Suffix", ]); }); it("handles double and single quotes combined", () => { const projectEnv = ` ENVIRONMENT=PRODUCTION APP_NAME=MyApp `; const serviceEnv = ` NODE_ENV="'\${{project.ENVIRONMENT}}'" COMPLEX_VAR="'Prefix \"DoubleQuoted\" and \${{project.APP_NAME}}'" `; const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv); expect(resolved).toEqual([ "NODE_ENV='PRODUCTION'", "COMPLEX_VAR='Prefix \"DoubleQuoted\" and MyApp'", ]); }); }); describe("prepareEnvironmentVariables (self references)", () => { it("resolves self references correctly", () => { const serviceEnv = ` ENVIRONMENT=staging DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db SELF_REF=\${{ENVIRONMENT}} `; const resolved = prepareEnvironmentVariables(serviceEnv, ""); expect(resolved).toEqual([ "ENVIRONMENT=staging", "DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db", "SELF_REF=staging", ]); }); it("throws on undefined self references", () => { const serviceEnv = ` MISSING_VAR=\${{UNDEFINED_VAR}} `; expect(() => prepareEnvironmentVariables(serviceEnv, "")).toThrow( "Invalid service environment variable: UNDEFINED_VAR", ); }); it("allows overriding and still resolving from self", () => { const serviceEnv = ` ENVIRONMENT=production OVERRIDE_ENV=\${{ENVIRONMENT}} `; const resolved = prepareEnvironmentVariables(serviceEnv, ""); expect(resolved).toEqual([ "ENVIRONMENT=production", "OVERRIDE_ENV=production", ]); }); it("resolves multiple self references inside one value", () => { const serviceEnv = ` ENVIRONMENT=staging APP_NAME=MyApp COMPLEX=\${{APP_NAME}}-\${{ENVIRONMENT}}-\${{APP_NAME}} `; const resolved = prepareEnvironmentVariables(serviceEnv, ""); expect(resolved).toEqual([ "ENVIRONMENT=staging", "APP_NAME=MyApp", "COMPLEX=MyApp-staging-MyApp", ]); }); it("handles quotes with self references", () => { const serviceEnv = ` ENVIRONMENT=production QUOTED="'\${{ENVIRONMENT}}'" MIXED="\"Double \${{ENVIRONMENT}}\"" `; const resolved = prepareEnvironmentVariables(serviceEnv, ""); expect(resolved).toEqual([ "ENVIRONMENT=production", "QUOTED='production'", 'MIXED="Double production"', ]); }); }); ================================================ FILE: apps/dokploy/__test__/env/stack-environment.test.ts ================================================ 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", }); }); }); ================================================ FILE: apps/dokploy/__test__/permissions/check-permission.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockMemberData = ( role: string, overrides: Record = {}, ) => ({ id: "member-1", role, userId: "user-1", organizationId: "org-1", accessedProjects: [] as string[], accessedServices: [] as string[], accessedEnvironments: [] as string[], canCreateProjects: overrides.canCreateProjects ?? false, canDeleteProjects: overrides.canDeleteProjects ?? false, canCreateServices: overrides.canCreateServices ?? false, canDeleteServices: overrides.canDeleteServices ?? false, canCreateEnvironments: overrides.canCreateEnvironments ?? false, canDeleteEnvironments: overrides.canDeleteEnvironments ?? false, canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false, canAccessToDocker: overrides.canAccessToDocker ?? false, canAccessToAPI: overrides.canAccessToAPI ?? false, canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false, canAccessToGitProviders: overrides.canAccessToGitProviders ?? false, user: { id: "user-1", email: "test@test.com" }, }); let memberToReturn: ReturnType = mockMemberData("member"); vi.mock("@dokploy/server/db", () => ({ db: { query: { member: { findFirst: vi.fn(() => Promise.resolve(memberToReturn)), findMany: vi.fn(() => Promise.resolve([])), }, organizationRole: { findFirst: vi.fn(), findMany: vi.fn(() => Promise.resolve([])), }, }, }, })); vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ hasValidLicense: vi.fn(() => Promise.resolve(false)), })); const { checkPermission } = await import("@dokploy/server/services/permission"); const ctx = { user: { id: "user-1" }, session: { activeOrganizationId: "org-1" }, }; beforeEach(() => { vi.clearAllMocks(); }); describe("static roles bypass enterprise resources", () => { it("owner bypasses deployment.read", async () => { memberToReturn = mockMemberData("owner"); await expect( checkPermission(ctx, { deployment: ["read"] }), ).resolves.toBeUndefined(); }); it("admin bypasses backup.create", async () => { memberToReturn = mockMemberData("admin"); await expect( checkPermission(ctx, { backup: ["create"] }), ).resolves.toBeUndefined(); }); it("member bypasses schedule.delete", async () => { memberToReturn = mockMemberData("member"); await expect( checkPermission(ctx, { schedule: ["delete"] }), ).resolves.toBeUndefined(); }); it("member bypasses multiple enterprise permissions at once", async () => { memberToReturn = mockMemberData("member"); await expect( checkPermission(ctx, { deployment: ["read"], backup: ["create"], domain: ["delete"], }), ).resolves.toBeUndefined(); }); }); describe("static roles validate free-tier resources", () => { it("owner passes project.create", async () => { memberToReturn = mockMemberData("owner"); await expect( checkPermission(ctx, { project: ["create"] }), ).resolves.toBeUndefined(); }); it("member fails project.create (no legacy override)", async () => { memberToReturn = mockMemberData("member"); await expect( checkPermission(ctx, { project: ["create"] }), ).rejects.toThrow(); }); it("member passes service.read", async () => { memberToReturn = mockMemberData("member"); await expect( checkPermission(ctx, { service: ["read"] }), ).resolves.toBeUndefined(); }); it("member fails service.create", async () => { memberToReturn = mockMemberData("member"); await expect( checkPermission(ctx, { service: ["create"] }), ).rejects.toThrow(); }); }); describe("legacy boolean overrides for member", () => { it("member passes project.create with canCreateProjects=true", async () => { memberToReturn = mockMemberData("member", { canCreateProjects: true }); await expect( checkPermission(ctx, { project: ["create"] }), ).resolves.toBeUndefined(); }); it("member passes docker.read with canAccessToDocker=true", async () => { memberToReturn = mockMemberData("member", { canAccessToDocker: true }); await expect( checkPermission(ctx, { docker: ["read"] }), ).resolves.toBeUndefined(); }); it("member fails docker.read with canAccessToDocker=false", async () => { memberToReturn = mockMemberData("member"); await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow(); }); }); ================================================ FILE: apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts ================================================ import { describe, it, expect } from "vitest"; import { enterpriseOnlyResources, statements, } from "@dokploy/server/lib/access-control"; const FREE_TIER_RESOURCES = [ "organization", "member", "invitation", "team", "ac", "project", "service", "environment", "docker", "sshKeys", "gitProviders", "traefikFiles", "api", ]; const ENTERPRISE_RESOURCES = [ "volume", "deployment", "envVars", "projectEnvVars", "environmentEnvVars", "server", "registry", "certificate", "backup", "volumeBackup", "schedule", "domain", "destination", "notification", "logs", "monitoring", "auditLog", ]; describe("enterpriseOnlyResources set", () => { it("contains all enterprise resources", () => { for (const resource of ENTERPRISE_RESOURCES) { expect(enterpriseOnlyResources.has(resource)).toBe(true); } }); it("does NOT contain free-tier resources", () => { for (const resource of FREE_TIER_RESOURCES) { expect(enterpriseOnlyResources.has(resource)).toBe(false); } }); it("every resource in statements is either free or enterprise", () => { const allResources = Object.keys(statements); for (const resource of allResources) { const isFree = FREE_TIER_RESOURCES.includes(resource); const isEnterprise = enterpriseOnlyResources.has(resource); expect(isFree || isEnterprise).toBe(true); } }); it("free and enterprise sets don't overlap", () => { for (const resource of FREE_TIER_RESOURCES) { expect(enterpriseOnlyResources.has(resource)).toBe(false); } }); it("all statement resources are accounted for", () => { const allResources = Object.keys(statements); const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES]; for (const resource of allResources) { expect(categorized).toContain(resource); } }); }); ================================================ FILE: apps/dokploy/__test__/permissions/resolve-permissions.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockMemberData = ( role: string, overrides: Record = {}, ) => ({ id: "member-1", role, userId: "user-1", organizationId: "org-1", accessedProjects: [] as string[], accessedServices: [] as string[], accessedEnvironments: [] as string[], canCreateProjects: overrides.canCreateProjects ?? false, canDeleteProjects: overrides.canDeleteProjects ?? false, canCreateServices: overrides.canCreateServices ?? false, canDeleteServices: overrides.canDeleteServices ?? false, canCreateEnvironments: overrides.canCreateEnvironments ?? false, canDeleteEnvironments: overrides.canDeleteEnvironments ?? false, canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false, canAccessToDocker: overrides.canAccessToDocker ?? false, canAccessToAPI: overrides.canAccessToAPI ?? false, canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false, canAccessToGitProviders: overrides.canAccessToGitProviders ?? false, user: { id: "user-1", email: "test@test.com" }, }); let memberToReturn: ReturnType = mockMemberData("member"); vi.mock("@dokploy/server/db", () => ({ db: { query: { member: { findFirst: vi.fn(() => Promise.resolve(memberToReturn)), findMany: vi.fn(() => Promise.resolve([])), }, organizationRole: { findFirst: vi.fn(), findMany: vi.fn(() => Promise.resolve([])), }, }, }, })); vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ hasValidLicense: vi.fn(() => Promise.resolve(false)), })); const { resolvePermissions } = await import( "@dokploy/server/services/permission" ); const { enterpriseOnlyResources, statements } = await import( "@dokploy/server/lib/access-control" ); const ctx = { user: { id: "user-1" }, session: { activeOrganizationId: "org-1" }, }; beforeEach(() => { vi.clearAllMocks(); }); describe("enterprise resources for static roles", () => { it("owner gets true for all enterprise resources", async () => { memberToReturn = mockMemberData("owner"); const perms = await resolvePermissions(ctx); for (const resource of enterpriseOnlyResources) { const actions = statements[resource as keyof typeof statements]; for (const action of actions) { expect((perms as any)[resource][action]).toBe(true); } } }); it("admin gets true for all enterprise resources", async () => { memberToReturn = mockMemberData("admin"); const perms = await resolvePermissions(ctx); for (const resource of enterpriseOnlyResources) { const actions = statements[resource as keyof typeof statements]; for (const action of actions) { expect((perms as any)[resource][action]).toBe(true); } } }); it("member gets true for service-level enterprise resources", async () => { memberToReturn = mockMemberData("member"); const perms = await resolvePermissions(ctx); expect(perms.deployment.read).toBe(true); expect(perms.deployment.create).toBe(true); expect(perms.domain.read).toBe(true); expect(perms.backup.read).toBe(true); expect(perms.logs.read).toBe(true); expect(perms.monitoring.read).toBe(true); }); it("member gets false for org-level enterprise resources", async () => { memberToReturn = mockMemberData("member"); const perms = await resolvePermissions(ctx); expect(perms.server.read).toBe(false); expect(perms.registry.read).toBe(false); expect(perms.certificate.read).toBe(false); expect(perms.destination.read).toBe(false); expect(perms.notification.read).toBe(false); expect(perms.auditLog.read).toBe(false); }); }); describe("free-tier resources for member", () => { it("member gets service.read=true", async () => { memberToReturn = mockMemberData("member"); const perms = await resolvePermissions(ctx); expect(perms.service.read).toBe(true); }); it("member gets project.create=false without legacy override", async () => { memberToReturn = mockMemberData("member"); const perms = await resolvePermissions(ctx); expect(perms.project.create).toBe(false); }); it("member gets project.create=true with canCreateProjects", async () => { memberToReturn = mockMemberData("member", { canCreateProjects: true }); const perms = await resolvePermissions(ctx); expect(perms.project.create).toBe(true); }); it("member gets docker.read=false without legacy override", async () => { memberToReturn = mockMemberData("member"); const perms = await resolvePermissions(ctx); expect(perms.docker.read).toBe(false); }); it("member gets docker.read=true with canAccessToDocker", async () => { memberToReturn = mockMemberData("member", { canAccessToDocker: true }); const perms = await resolvePermissions(ctx); expect(perms.docker.read).toBe(true); }); }); describe("free-tier resources for owner", () => { it("owner gets all free-tier permissions as true", async () => { memberToReturn = mockMemberData("owner"); const perms = await resolvePermissions(ctx); expect(perms.project.create).toBe(true); expect(perms.project.delete).toBe(true); expect(perms.service.create).toBe(true); expect(perms.service.read).toBe(true); expect(perms.service.delete).toBe(true); expect(perms.docker.read).toBe(true); expect(perms.traefikFiles.read).toBe(true); expect(perms.traefikFiles.write).toBe(true); }); }); ================================================ FILE: apps/dokploy/__test__/permissions/service-access.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockMemberData = ( role: string, accessedServices: string[] = [], accessedProjects: string[] = [], ) => ({ id: "member-1", role, userId: "user-1", organizationId: "org-1", accessedProjects, accessedServices, accessedEnvironments: [] as string[], canCreateProjects: false, canDeleteProjects: false, canCreateServices: false, canDeleteServices: false, canCreateEnvironments: false, canDeleteEnvironments: false, canAccessToTraefikFiles: false, canAccessToDocker: false, canAccessToAPI: false, canAccessToSSHKeys: false, canAccessToGitProviders: false, user: { id: "user-1", email: "test@test.com" }, }); let memberToReturn: ReturnType = mockMemberData("member"); vi.mock("@dokploy/server/db", () => ({ db: { query: { member: { findFirst: vi.fn(() => Promise.resolve(memberToReturn)), findMany: vi.fn(() => Promise.resolve([])), }, organizationRole: { findFirst: vi.fn(), findMany: vi.fn(() => Promise.resolve([])), }, }, }, })); vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ hasValidLicense: vi.fn(() => Promise.resolve(false)), })); const { checkServicePermissionAndAccess, checkServiceAccess } = await import( "@dokploy/server/services/permission" ); const ctx = { user: { id: "user-1" }, session: { activeOrganizationId: "org-1" }, }; beforeEach(() => { vi.clearAllMocks(); }); describe("checkServicePermissionAndAccess", () => { it("owner bypasses accessedServices check", async () => { memberToReturn = mockMemberData("owner", []); await expect( checkServicePermissionAndAccess(ctx, "service-123", { deployment: ["read"], }), ).resolves.toBeUndefined(); }); it("admin bypasses accessedServices check", async () => { memberToReturn = mockMemberData("admin", []); await expect( checkServicePermissionAndAccess(ctx, "service-123", { backup: ["create"], }), ).resolves.toBeUndefined(); }); it("member with access to service passes", async () => { memberToReturn = mockMemberData("member", ["service-123"]); await expect( checkServicePermissionAndAccess(ctx, "service-123", { deployment: ["read"], }), ).resolves.toBeUndefined(); }); it("member WITHOUT access to service fails", async () => { memberToReturn = mockMemberData("member", ["other-service"]); await expect( checkServicePermissionAndAccess(ctx, "service-123", { deployment: ["read"], }), ).rejects.toThrow("You don't have access to this service"); }); it("member with empty accessedServices fails", async () => { memberToReturn = mockMemberData("member", []); await expect( checkServicePermissionAndAccess(ctx, "service-123", { domain: ["delete"], }), ).rejects.toThrow("You don't have access to this service"); }); }); describe("checkServiceAccess", () => { it("member with service access passes read check", async () => { memberToReturn = mockMemberData("member", ["app-1"]); await expect( checkServiceAccess(ctx, "app-1", "read"), ).resolves.toBeUndefined(); }); it("member without service access fails read check", async () => { memberToReturn = mockMemberData("member", []); await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow( "You don't have access to this service", ); }); it("owner bypasses all access checks", async () => { memberToReturn = mockMemberData("owner", [], []); await expect( checkServiceAccess(ctx, "project-1", "create"), ).resolves.toBeUndefined(); }); }); ================================================ FILE: apps/dokploy/__test__/requests/request.test.ts ================================================ import { parseRawConfig, processLogs } from "@dokploy/server"; import { describe, expect, it } from "vitest"; const sampleLogEntry = `{"ClientAddr":"172.19.0.1:56732","ClientHost":"172.19.0.1","ClientPort":"56732","ClientUsername":"-","DownstreamContentSize":0,"DownstreamStatus":304,"Duration":14729375,"OriginContentSize":0,"OriginDuration":14051833,"OriginStatus":304,"Overhead":677542,"RequestAddr":"s222-umami-c381af.traefik.me","RequestContentSize":0,"RequestCount":122,"RequestHost":"s222-umami-c381af.traefik.me","RequestMethod":"GET","RequestPath":"/dashboard?_rsc=1rugv","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-umami-60e104-47-web@docker","ServiceAddr":"10.0.1.15:3000","ServiceName":"s222-umami-60e104-47-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.15:3000","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T04:34:37.306691884Z","StartUTC":"2024-08-25T04:34:37.306691884Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T04:34:37Z"}`; describe("processLogs", () => { it("should process a single log entry correctly", () => { const result = processLogs(sampleLogEntry); expect(result).toHaveLength(1); expect(result[0]).toEqual({ hour: "2024-08-25T04:00:00Z", count: 1, }); }); it("should process multiple log entries and group by hour", () => { const sampleLogEntry = `{"ClientAddr":"172.19.0.1:58094","ClientHost":"172.19.0.1","ClientPort":"58094","ClientUsername":"-","DownstreamContentSize":50,"DownstreamStatus":200,"Duration":35914250,"OriginContentSize":50,"OriginDuration":35817959,"OriginStatus":200,"Overhead":96291,"RequestAddr":"s222-pocketbase-f4a6e5.traefik.me","RequestContentSize":0,"RequestCount":991,"RequestHost":"s222-pocketbase-f4a6e5.traefik.me","RequestMethod":"GET","RequestPath":"/api/logs/stats?filter=","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-pocketbase-e94e25-44-web@docker","ServiceAddr":"10.0.1.12:80","ServiceName":"s222-pocketbase-e94e25-44-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.12:80","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T17:44:29.274072471Z","StartUTC":"2024-08-25T17:44:29.274072471Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T17:44:29Z"} {"ClientAddr":"172.19.0.1:58108","ClientHost":"172.19.0.1","ClientPort":"58108","ClientUsername":"-","DownstreamContentSize":30975,"DownstreamStatus":200,"Duration":31406458,"OriginContentSize":30975,"OriginDuration":31046791,"OriginStatus":200,"Overhead":359667,"RequestAddr":"s222-pocketbase-f4a6e5.traefik.me","RequestContentSize":0,"RequestCount":992,"RequestHost":"s222-pocketbase-f4a6e5.traefik.me","RequestMethod":"GET","RequestPath":"/api/logs?page=1\u0026perPage=50\u0026sort=-rowid\u0026skipTotal=1\u0026filter=","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-pocketbase-e94e25-44-web@docker","ServiceAddr":"10.0.1.12:80","ServiceName":"s222-pocketbase-e94e25-44-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.12:80","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T17:44:29.278990221Z","StartUTC":"2024-08-25T17:44:29.278990221Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T17:44:29Z"} `; const result = processLogs(sampleLogEntry); expect(result).toHaveLength(1); expect(result).toEqual([{ hour: "2024-08-25T17:00:00Z", count: 2 }]); }); it("should return an empty array for empty input", () => { expect(processLogs("")).toEqual([]); expect(processLogs(null as any)).toEqual([]); expect(processLogs(undefined as any)).toEqual([]); }); // it("should parse a single log entry correctly", () => { // const result = parseRawConfig(sampleLogEntry); // expect(result).toHaveLength(1); // expect(result.data[0]).toHaveProperty("ClientAddr", "172.19.0.1:56732"); // expect(result.data[0]).toHaveProperty( // "StartUTC", // "2024-08-25T04:34:37.306691884Z", // ); // }); it("should parse multiple log entries", () => { const multipleEntries = `${sampleLogEntry}\n${sampleLogEntry}`; const result = parseRawConfig(multipleEntries); expect(result.data).toHaveLength(2); for (const entry of result.data) { expect(entry).toHaveProperty("ClientAddr", "172.19.0.1:56732"); } }); it("should handle whitespace and empty lines", () => { const entryWithWhitespace = `\n${sampleLogEntry}\n\n${sampleLogEntry}\n`; 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", ); }); }); ================================================ FILE: apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts ================================================ 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 = { TaskTemplate?: { ContainerSpec?: { StopGracePeriod?: number; Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>; }; }; [key: string]: unknown; }; const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } = vi.hoisted(() => { const inspect = vi.fn<() => Promise>(); const getService = vi.fn(() => ({ inspect })); const createService = vi.fn< (opts: MockCreateServiceOptions) => Promise >(async () => undefined); const getRemoteDocker = vi.fn(async () => ({ getService, createService, })); return { inspectMock: inspect, getServiceMock: getService, createServiceMock: createService, getRemoteDockerMock: getRemoteDocker, }; }); vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ getRemoteDocker: getRemoteDockerMock, })); const createApplication = ( overrides: Partial = {}, ): ApplicationNested => ({ appName: "test-app", buildType: "dockerfile", env: null, mounts: [], cpuLimit: null, memoryLimit: null, memoryReservation: null, cpuReservation: null, command: null, ports: [], sourceType: "docker", dockerImage: "example:latest", registry: null, environment: { project: { env: null }, env: null, }, replicas: 1, stopGracePeriodSwarm: 0n, ulimitsSwarm: null, serverId: "server-id", ...overrides, }) as unknown as ApplicationNested; describe("mechanizeDockerContainer", () => { beforeEach(() => { inspectMock.mockReset(); inspectMock.mockRejectedValue(new Error("service not found")); getServiceMock.mockClear(); createServiceMock.mockClear(); getRemoteDockerMock.mockClear(); getRemoteDockerMock.mockResolvedValue({ getService: getServiceMock, createService: createServiceMock, }); }); it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => { const application = createApplication({ stopGracePeriodSwarm: 0n }); await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); const call = createServiceMock.mock.calls[0] as | [MockCreateServiceOptions] | undefined; if (!call) { throw new Error("createServiceMock should have been called once"); } const [settings] = call; expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0); expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe( "number", ); }); it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => { const application = createApplication({ stopGracePeriodSwarm: null }); await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); const call = createServiceMock.mock.calls[0] as | [MockCreateServiceOptions] | undefined; if (!call) { throw new Error("createServiceMock should have been called once"); } const [settings] = call; expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty( "StopGracePeriod", ); }); it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => { const ulimits = [ { Name: "nofile", Soft: 10000, Hard: 20000 }, { Name: "nproc", Soft: 4096, Hard: 8192 }, ]; const application = createApplication({ ulimitsSwarm: ulimits }); await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); const call = createServiceMock.mock.calls[0]; if (!call) { throw new Error("createServiceMock should have been called once"); } const [settings] = call; expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits); }); it("omits Ulimits when ulimitsSwarm is null", async () => { const application = createApplication({ ulimitsSwarm: null }); await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); const call = createServiceMock.mock.calls[0]; if (!call) { throw new Error("createServiceMock should have been called once"); } const [settings] = call; expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); }); it("omits Ulimits when ulimitsSwarm is an empty array", async () => { const application = createApplication({ ulimitsSwarm: [] }); await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); const call = createServiceMock.mock.calls[0]; if (!call) { throw new Error("createServiceMock should have been called once"); } const [settings] = call; expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); }); }); ================================================ FILE: apps/dokploy/__test__/setup.ts ================================================ import { vi } from "vitest"; /** * Mock the DB module so tests that import from @dokploy/server (barrel) * never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs). * Without this, loading the server barrel pulls in lib/auth and db, which * connect to localhost:5432 and cause ECONNREFUSED. */ vi.mock("@dokploy/server/db", () => { const chain = () => chain; chain.set = () => chain; chain.where = () => chain; chain.values = () => chain; chain.returning = () => Promise.resolve([{}]); chain.from = () => chain; chain.innerJoin = () => chain; chain.then = (resolve: (value: unknown) => void) => { resolve([]); }; const tableMock = { findFirst: vi.fn(() => Promise.resolve(undefined)), findMany: vi.fn(() => Promise.resolve([])), insert: vi.fn(() => Promise.resolve([{}])), update: vi.fn(() => chain), delete: vi.fn(() => chain), }; return { db: { select: vi.fn(() => chain), insert: vi.fn(() => ({ values: () => ({ returning: () => Promise.resolve([{}]) }), })), update: vi.fn(() => chain), delete: vi.fn(() => chain), query: new Proxy({} as Record, { get: () => tableMock, }), }, dbUrl: "postgres://mock:mock@localhost:5432/mock", }; }); ================================================ FILE: apps/dokploy/__test__/templates/config.template.test.ts ================================================ import type { Schema } from "@dokploy/server/templates"; import type { CompleteTemplate } from "@dokploy/server/templates/processors"; import { processTemplate } from "@dokploy/server/templates/processors"; import { describe, expect, it } from "vitest"; describe("processTemplate", () => { // Mock schema for testing const mockSchema: Schema = { projectName: "test", serverIp: "127.0.0.1", }; describe("variables processing", () => { it("should process basic variables with utility functions", () => { const template: CompleteTemplate = { metadata: {} as any, variables: { main_domain: "${domain}", secret_base: "${base64:64}", totp_key: "${base64:32}", password: "${password:32}", hash: "${hash:16}", }, config: { domains: [], env: {}, }, }; const result = processTemplate(template, mockSchema); expect(result.envs).toHaveLength(0); expect(result.domains).toHaveLength(0); expect(result.mounts).toHaveLength(0); }); it("should allow referencing variables in other variables", () => { const template: CompleteTemplate = { metadata: {} as any, variables: { main_domain: "${domain}", api_domain: "api.${main_domain}", }, config: { domains: [], env: {}, }, }; const result = processTemplate(template, mockSchema); expect(result.envs).toHaveLength(0); expect(result.domains).toHaveLength(0); expect(result.mounts).toHaveLength(0); }); it("should allow creation of real jwt secret", () => { const template: CompleteTemplate = { metadata: {} as any, variables: { jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ", anon_payload: JSON.stringify({ role: "tester", iss: "dockploy", iat: "${timestamps:2025-01-01T00:00:00Z}", exp: "${timestamps:2030-01-01T00:00:00Z}", }), anon_key: "${jwt:jwt_secret:anon_payload}", }, config: { domains: [], env: { ANON_KEY: "${anon_key}", }, }, }; const result = processTemplate(template, mockSchema); expect(result.envs).toHaveLength(1); expect(result.envs).toContain( "ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY", ); expect(result.mounts).toHaveLength(0); expect(result.domains).toHaveLength(0); }); }); describe("domains processing", () => { it("should process domains with explicit host", () => { const template: CompleteTemplate = { metadata: {} as any, variables: { main_domain: "${domain}", }, config: { domains: [ { serviceName: "plausible", port: 8000, host: "${main_domain}", }, ], env: {}, }, }; const result = processTemplate(template, mockSchema); expect(result.domains).toHaveLength(1); const domain = result.domains[0]; expect(domain).toBeDefined(); if (!domain) return; expect(domain).toMatchObject({ serviceName: "plausible", port: 8000, }); expect(domain.host).toBeDefined(); expect(domain.host).toContain(mockSchema.projectName); }); it("should generate random domain if host is not specified", () => { const template: CompleteTemplate = { metadata: {} as any, variables: {}, config: { domains: [ { serviceName: "plausible", port: 8000, }, ], env: {}, }, }; const result = processTemplate(template, mockSchema); expect(result.domains).toHaveLength(1); const domain = result.domains[0]; expect(domain).toBeDefined(); if (!domain || !domain.host) return; expect(domain.host).toBeDefined(); expect(domain.host).toContain(mockSchema.projectName); }); it("should allow using ${domain} directly in host", () => { const template: CompleteTemplate = { metadata: {} as any, variables: {}, config: { domains: [ { serviceName: "plausible", port: 8000, host: "${domain}", }, ], env: {}, }, }; const result = processTemplate(template, mockSchema); expect(result.domains).toHaveLength(1); const domain = result.domains[0]; expect(domain).toBeDefined(); if (!domain || !domain.host) return; expect(domain.host).toBeDefined(); expect(domain.host).toContain(mockSchema.projectName); }); }); describe("environment variables processing", () => { it("should process env vars with variable references", () => { const template: CompleteTemplate = { metadata: {} as any, variables: { main_domain: "${domain}", secret_base: "${base64:64}", }, config: { domains: [], env: { BASE_URL: "http://${main_domain}", SECRET_KEY_BASE: "${secret_base}", }, }, }; const result = processTemplate(template, mockSchema); expect(result.envs).toHaveLength(2); const baseUrl = result.envs.find((env: string) => env.startsWith("BASE_URL="), ); const secretKey = result.envs.find((env: string) => env.startsWith("SECRET_KEY_BASE="), ); expect(baseUrl).toBeDefined(); expect(secretKey).toBeDefined(); if (!baseUrl || !secretKey) return; expect(baseUrl).toContain(mockSchema.projectName); const base64Value = secretKey.split("=")[1]; expect(base64Value).toBeDefined(); if (!base64Value) return; expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); expect(base64Value.length).toBeGreaterThanOrEqual(86); expect(base64Value.length).toBeLessThanOrEqual(88); }); it("should process env vars when provided as an array", () => { const template: CompleteTemplate = { metadata: {} as any, variables: {}, config: { domains: [], env: [ 'CLOUDFLARE_TUNNEL_TOKEN=""', 'ANOTHER_VAR="some value"', "DOMAIN=${domain}", ], mounts: [], }, }; const result = processTemplate(template, mockSchema); expect(result.envs).toHaveLength(3); // Should preserve exact format for static values expect(result.envs[0]).toBe('CLOUDFLARE_TUNNEL_TOKEN=""'); expect(result.envs[1]).toBe('ANOTHER_VAR="some value"'); // Should process variables in array items expect(result.envs[2]).toContain(mockSchema.projectName); }); it("should allow using utility functions directly in env vars", () => { const template: CompleteTemplate = { metadata: {} as any, variables: {}, config: { domains: [], env: { RANDOM_DOMAIN: "${domain}", SECRET_KEY: "${base64:32}", }, }, }; const result = processTemplate(template, mockSchema); expect(result.envs).toHaveLength(2); const randomDomainEnv = result.envs.find((env: string) => env.startsWith("RANDOM_DOMAIN="), ); const secretKeyEnv = result.envs.find((env: string) => env.startsWith("SECRET_KEY="), ); expect(randomDomainEnv).toBeDefined(); expect(secretKeyEnv).toBeDefined(); if (!randomDomainEnv || !secretKeyEnv) return; expect(randomDomainEnv).toContain(mockSchema.projectName); const base64Value = secretKeyEnv.split("=")[1]; expect(base64Value).toBeDefined(); if (!base64Value) return; expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); expect(base64Value.length).toBeGreaterThanOrEqual(42); expect(base64Value.length).toBeLessThanOrEqual(44); }); it("should handle boolean values in env vars when provided as an array", () => { const template: CompleteTemplate = { metadata: {} as any, variables: {}, config: { domains: [], env: [ "ENABLE_USER_SIGN_UP=false", "DEBUG_MODE=true", "SOME_NUMBER=42", ], mounts: [], }, }; const result = processTemplate(template, mockSchema); expect(result.envs).toHaveLength(3); expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false"); expect(result.envs).toContain("DEBUG_MODE=true"); expect(result.envs).toContain("SOME_NUMBER=42"); }); it("should handle boolean values in env vars when provided as an object", () => { const template: CompleteTemplate = { metadata: {} as any, variables: {}, config: { domains: [], env: { ENABLE_USER_SIGN_UP: false, DEBUG_MODE: true, SOME_NUMBER: 42, }, }, }; const result = processTemplate(template, mockSchema); expect(result.envs).toHaveLength(3); expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false"); expect(result.envs).toContain("DEBUG_MODE=true"); expect(result.envs).toContain("SOME_NUMBER=42"); }); }); describe("mounts processing", () => { it("should process mounts with variable references", () => { const template: CompleteTemplate = { metadata: {} as any, variables: { config_path: "/etc/config", secret_key: "${base64:32}", }, config: { domains: [], env: {}, mounts: [ { filePath: "${config_path}/config.xml", content: "secret_key=${secret_key}", }, ], }, }; const result = processTemplate(template, mockSchema); expect(result.mounts).toHaveLength(1); const mount = result.mounts[0]; expect(mount).toBeDefined(); if (!mount) return; expect(mount.filePath).toContain("/etc/config"); expect(mount.content).toMatch(/secret_key=[A-Za-z0-9+/]{32}/); }); it("should allow using utility functions directly in mount content", () => { const template: CompleteTemplate = { metadata: {} as any, variables: {}, config: { domains: [], env: {}, mounts: [ { filePath: "/config/secrets.txt", content: "random_domain=${domain}\nsecret=${base64:32}", }, ], }, }; const result = processTemplate(template, mockSchema); expect(result.mounts).toHaveLength(1); const mount = result.mounts[0]; expect(mount).toBeDefined(); if (!mount) return; expect(mount.content).toContain(mockSchema.projectName); expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{32}/); }); }); describe("complex template processing", () => { it("should process a complete template with all features", () => { const template: CompleteTemplate = { metadata: {} as any, variables: { main_domain: "${domain}", secret_base: "${base64:64}", totp_key: "${base64:32}", }, config: { domains: [ { serviceName: "plausible", port: 8000, host: "${main_domain}", }, { serviceName: "api", port: 3000, host: "api.${main_domain}", }, ], env: { BASE_URL: "http://${main_domain}", SECRET_KEY_BASE: "${secret_base}", TOTP_VAULT_KEY: "${totp_key}", }, mounts: [ { filePath: "/config/app.conf", content: ` domain=\${main_domain} secret=\${secret_base} totp=\${totp_key} `, }, ], }, }; const result = processTemplate(template, mockSchema); // Check domains expect(result.domains).toHaveLength(2); const [domain1, domain2] = result.domains; expect(domain1).toBeDefined(); expect(domain2).toBeDefined(); if (!domain1 || !domain2) return; expect(domain1.host).toBeDefined(); expect(domain1.host).toContain(mockSchema.projectName); expect(domain2.host).toContain("api."); expect(domain2.host).toContain(mockSchema.projectName); // Check env vars expect(result.envs).toHaveLength(3); const baseUrl = result.envs.find((env: string) => env.startsWith("BASE_URL="), ); const secretKey = result.envs.find((env: string) => env.startsWith("SECRET_KEY_BASE="), ); const totpKey = result.envs.find((env: string) => env.startsWith("TOTP_VAULT_KEY="), ); expect(baseUrl).toBeDefined(); expect(secretKey).toBeDefined(); expect(totpKey).toBeDefined(); if (!baseUrl || !secretKey || !totpKey) return; expect(baseUrl).toContain(mockSchema.projectName); // Check base64 lengths and format const secretKeyValue = secretKey.split("=")[1]; const totpKeyValue = totpKey.split("=")[1]; expect(secretKeyValue).toBeDefined(); expect(totpKeyValue).toBeDefined(); if (!secretKeyValue || !totpKeyValue) return; expect(secretKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); expect(secretKeyValue.length).toBeGreaterThanOrEqual(86); expect(secretKeyValue.length).toBeLessThanOrEqual(88); expect(totpKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); expect(totpKeyValue.length).toBeGreaterThanOrEqual(42); expect(totpKeyValue.length).toBeLessThanOrEqual(44); // Check mounts expect(result.mounts).toHaveLength(1); const mount = result.mounts[0]; expect(mount).toBeDefined(); if (!mount) return; expect(mount.content).toContain(mockSchema.projectName); expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{86,88}/); expect(mount.content).toMatch(/totp=[A-Za-z0-9+/]{42,44}/); }); }); describe("Should populate envs, domains and mounts in the case we didn't used any variable", () => { it("should populate envs, domains and mounts in the case we didn't used any variable", () => { const template: CompleteTemplate = { metadata: {} as any, variables: {}, config: { domains: [ { serviceName: "plausible", port: 8000, host: "${hash}", }, ], env: { BASE_URL: "http://${domain}", SECRET_KEY_BASE: "${password:32}", TOTP_VAULT_KEY: "${base64:128}", }, mounts: [ { filePath: "/config/secrets.txt", content: "random_domain=${domain}\nsecret=${password:32}", }, ], }, }; const result = processTemplate(template, mockSchema); expect(result.envs).toHaveLength(3); expect(result.domains).toHaveLength(1); expect(result.mounts).toHaveLength(1); }); }); }); ================================================ FILE: apps/dokploy/__test__/templates/helpers.template.test.ts ================================================ import type { Schema } from "@dokploy/server/templates"; import { processValue } from "@dokploy/server/templates/processors"; import { describe, expect, it } from "vitest"; describe("helpers functions", () => { // Mock schema for testing const mockSchema: Schema = { projectName: "test", serverIp: "127.0.0.1", }; // some helpers to test jwt type JWTParts = [string, string, string]; const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/; const jwtBase64Decode = (str: string) => { const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); const padding = "=".repeat((4 - (base64.length % 4)) % 4); const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8"); return JSON.parse(decoded); }; const jwtCheckHeader = (jwtHeader: string) => { const decodedHeader = jwtBase64Decode(jwtHeader); expect(decodedHeader).toHaveProperty("alg"); expect(decodedHeader).toHaveProperty("typ"); expect(decodedHeader.alg).toEqual("HS256"); expect(decodedHeader.typ).toEqual("JWT"); }; describe("${domain}", () => { it("should generate a random domain", () => { const domain = processValue("${domain}", {}, mockSchema); expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy(); expect( domain.endsWith( `${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`, ), ).toBeTruthy(); }); }); describe("${base64}", () => { it("should generate a base64 string", () => { const base64 = processValue("${base64}", {}, mockSchema); expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/); }); it.each([ [4, 8], [8, 12], [16, 24], [32, 44], [64, 88], [128, 172], ])( "should generate a base64 string from parameter %d bytes length", (length, finalLength) => { const base64 = processValue(`\${base64:${length}}`, {}, mockSchema); expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/); expect(base64.length).toBe(finalLength); }, ); }); describe("${password}", () => { it("should generate a password string", () => { const password = processValue("${password}", {}, mockSchema); expect(password).toMatch(/^[A-Za-z0-9]+$/); }); it.each([6, 8, 12, 16, 32])( "should generate a password string respecting parameter %d length", (length) => { const password = processValue(`\${password:${length}}`, {}, mockSchema); expect(password).toMatch(/^[A-Za-z0-9]+$/); expect(password.length).toBe(length); }, ); }); describe("${hash}", () => { it("should generate a hash string", () => { const hash = processValue("${hash}", {}, mockSchema); expect(hash).toMatch(/^[A-Za-z0-9]+$/); }); it.each([6, 8, 12, 16, 32])( "should generate a hash string respecting parameter %d length", (length) => { const hash = processValue(`\${hash:${length}}`, {}, mockSchema); expect(hash).toMatch(/^[A-Za-z0-9]+$/); expect(hash.length).toBe(length); }, ); }); describe("${uuid}", () => { it("should generate a UUID string", () => { const uuid = processValue("${uuid}", {}, mockSchema); expect(uuid).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ); }); }); describe("${timestamp}", () => { it("should generate a timestamp string in milliseconds", () => { const timestamp = processValue("${timestamp}", {}, mockSchema); const nowLength = Math.floor(Date.now()).toString().length; expect(timestamp).toMatch(/^\d+$/); expect(timestamp.length).toBe(nowLength); }); }); describe("${timestampms}", () => { it("should generate a timestamp string in milliseconds", () => { const timestamp = processValue("${timestampms}", {}, mockSchema); const nowLength = Date.now().toString().length; expect(timestamp).toMatch(/^\d+$/); expect(timestamp.length).toBe(nowLength); }); it("should generate a timestamp string in milliseconds from parameter", () => { const timestamp = processValue( "${timestampms:2025-01-01}", {}, mockSchema, ); expect(timestamp).toEqual("1735689600000"); }); }); describe("${timestamps}", () => { it("should generate a timestamp string in seconds", () => { const timestamps = processValue("${timestamps}", {}, mockSchema); const nowLength = Math.floor(Date.now() / 1000).toString().length; expect(timestamps).toMatch(/^\d+$/); expect(timestamps.length).toBe(nowLength); }); it("should generate a timestamp string in seconds from parameter", () => { const timestamps = processValue( "${timestamps:2025-01-01}", {}, mockSchema, ); expect(timestamps).toEqual("1735689600"); }); }); describe("${randomPort}", () => { it("should generate a random port string", () => { const randomPort = processValue("${randomPort}", {}, mockSchema); expect(randomPort).toMatch(/^\d+$/); expect(Number(randomPort)).toBeLessThan(65536); }); }); describe("${username}", () => { it("should generate a username string", () => { const username = processValue("${username}", {}, mockSchema); expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/); }); }); describe("${email}", () => { it("should generate an email string", () => { const email = processValue("${email}", {}, mockSchema); expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/); }); }); 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); expect(jwt).toMatch(jwtMatchExp); const parts = jwt.split(".") as JWTParts; const decodedPayload = jwtBase64Decode(parts[1]); jwtCheckHeader(parts[0]); expect(decodedPayload).toHaveProperty("iat"); expect(decodedPayload).toHaveProperty("iss"); expect(decodedPayload).toHaveProperty("exp"); expect(decodedPayload.iss).toEqual("dokploy"); }); it.each([6, 8, 12, 16, 32])( "should generate a random hex string from parameter %d byte length", (length) => { const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema); expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/); expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length expect(jwt.length).toBeLessThanOrEqual(length * 2); }, ); }); describe("${jwt:secret}", () => { it("should generate a JWT string respecting parameter secret from variable", () => { const jwt = processValue( "${jwt:secret}", { secret: "mysecret" }, mockSchema, ); expect(jwt).toMatch(jwtMatchExp); const parts = jwt.split(".") as JWTParts; const decodedPayload = jwtBase64Decode(parts[1]); jwtCheckHeader(parts[0]); expect(decodedPayload).toHaveProperty("iat"); expect(decodedPayload).toHaveProperty("iss"); expect(decodedPayload).toHaveProperty("exp"); expect(decodedPayload.iss).toEqual("dokploy"); }); }); describe("${jwt:secret:payload}", () => { it("should generate a JWT string respecting parameters secret and payload from variables", () => { const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); const expiry = iat + 3600; const jwt = processValue( "${jwt:secret:payload}", { secret: "mysecret", payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`, }, mockSchema, ); expect(jwt).toMatch(jwtMatchExp); const parts = jwt.split(".") as JWTParts; jwtCheckHeader(parts[0]); const decodedPayload = jwtBase64Decode(parts[1]); expect(decodedPayload).toHaveProperty("iat"); expect(decodedPayload.iat).toEqual(iat); expect(decodedPayload).toHaveProperty("iss"); expect(decodedPayload.iss).toEqual("test-issuer"); expect(decodedPayload).toHaveProperty("exp"); expect(decodedPayload.exp).toEqual(expiry); expect(decodedPayload).toHaveProperty("customprop"); expect(decodedPayload.customprop).toEqual("customvalue"); expect(jwt).toEqual( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI", ); }); it("should handle JWT payload with newlines and whitespace by trimming them", () => { const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); const expiry = iat + 3600; const payloadWithNewlines = `{ "role": "anon", "iss": "supabase", "exp": ${expiry} } `; const jwt = processValue( "${jwt:secret:payload}", { secret: "mysecret", payload: payloadWithNewlines, }, mockSchema, ); expect(jwt).toMatch(jwtMatchExp); const parts = jwt.split(".") as JWTParts; jwtCheckHeader(parts[0]); const decodedPayload = jwtBase64Decode(parts[1]); expect(decodedPayload).toHaveProperty("role"); expect(decodedPayload.role).toEqual("anon"); expect(decodedPayload).toHaveProperty("iss"); expect(decodedPayload.iss).toEqual("supabase"); expect(decodedPayload).toHaveProperty("exp"); expect(decodedPayload.exp).toEqual(expiry); }); it("should handle JWT payload with leading and trailing whitespace", () => { const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); const expiry = iat + 3600; const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `; const jwt = processValue( "${jwt:secret:payload}", { secret: "mysecret", payload: payloadWithWhitespace, }, mockSchema, ); expect(jwt).toMatch(jwtMatchExp); const parts = jwt.split(".") as JWTParts; jwtCheckHeader(parts[0]); const decodedPayload = jwtBase64Decode(parts[1]); expect(decodedPayload).toHaveProperty("role"); expect(decodedPayload.role).toEqual("service_role"); expect(decodedPayload).toHaveProperty("iss"); expect(decodedPayload.iss).toEqual("supabase"); expect(decodedPayload).toHaveProperty("exp"); expect(decodedPayload.exp).toEqual(expiry); }); }); }); ================================================ FILE: apps/dokploy/__test__/traefik/server/update-server-config.test.ts ================================================ import { fs, vol } from "memfs"; vi.mock("node:fs", () => ({ ...fs, default: fs, })); 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"; type WebServerSettings = typeof webServerSettings.$inferSelect; const baseSettings: WebServerSettings = { id: "", https: false, certificateType: "none", host: null, serverIp: null, letsEncryptEmail: null, sshPrivateKey: null, enableDockerCleanup: false, logCleanupCron: null, metricsConfig: { containers: { refreshRate: 20, services: { include: [], exclude: [], }, }, server: { type: "Dokploy", cronJob: "", port: 4500, refreshRate: 20, retentionDays: 2, token: "", thresholds: { cpu: 0, memory: 0, }, urlCallback: "", }, }, whitelabelingConfig: { appName: null, appDescription: null, logoUrl: null, faviconUrl: null, customCss: null, loginLogoUrl: null, supportUrl: null, docsUrl: null, errorPageTitle: null, errorPageDescription: null, metaTitle: null, footerText: null, }, cleanupCacheApplications: false, cleanupCacheOnCompose: false, cleanupCacheOnPreviews: false, createdAt: null, updatedAt: new Date(), }; beforeEach(() => { vol.reset(); createDefaultServerTraefikConfig(); }); test("Should read the configuration file", () => { const config: FileConfig = loadOrCreateConfig("dokploy"); expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe( "dokploy-service-app", ); }); test("Should apply redirect-to-https", () => { updateServerTraefik( { ...baseSettings, https: true, certificateType: "letsencrypt", }, "example.com", ); const config: FileConfig = loadOrCreateConfig("dokploy"); expect(config.http?.routers?.["dokploy-router-app"]?.middlewares).toContain( "redirect-to-https", ); }); test("Should change only host when no certificate", () => { updateServerTraefik(baseSettings, "example.com"); const config: FileConfig = loadOrCreateConfig("dokploy"); expect(config.http?.routers?.["dokploy-router-app-secure"]).toBeUndefined(); }); test("Should not touch config without host", () => { const originalConfig: FileConfig = loadOrCreateConfig("dokploy"); updateServerTraefik(baseSettings, null); const config: FileConfig = loadOrCreateConfig("dokploy"); expect(originalConfig).toEqual(config); }); test("Should remove websecure if https rollback to http", () => { updateServerTraefik( { ...baseSettings, certificateType: "letsencrypt" }, "example.com", ); updateServerTraefik( { ...baseSettings, certificateType: "none" }, "example.com", ); const config: FileConfig = loadOrCreateConfig("dokploy"); expect(config.http?.routers?.["dokploy-router-app-secure"]).toBeUndefined(); expect( config.http?.routers?.["dokploy-router-app"]?.middlewares, ).not.toContain("redirect-to-https"); }); ================================================ FILE: apps/dokploy/__test__/traefik/traefik.test.ts ================================================ import type { ApplicationNested, Domain, Redirect } from "@dokploy/server"; import { createRouterConfig } from "@dokploy/server"; import { expect, test } from "vitest"; const baseApp: ApplicationNested = { 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, previewRequireCollaboratorPermissions: false, serverId: "", branch: null, dockerBuildStage: "", registryUrl: "", watchPaths: [], buildArgs: null, buildSecrets: null, isPreviewDeploymentsActive: false, previewBuildArgs: null, previewBuildSecrets: null, triggerType: "push", previewCertificateType: "none", previewEnv: null, previewHttps: false, previewPath: "/", previewPort: 3000, previewLimit: 0, previewCustomCertResolver: null, previewWildcard: "", environmentId: "", environment: { env: "", isDefault: false, environmentId: "", name: "", createdAt: "", description: "", projectId: "", project: { env: "", organizationId: "", name: "", description: "", createdAt: "", projectId: "", }, }, buildPath: "/", gitlabPathNamespace: "", buildType: "nixpacks", bitbucketBranch: "", bitbucketBuildPath: "", bitbucketId: "", bitbucketRepository: "", bitbucketOwner: "", githubId: "", gitlabProjectId: 0, gitlabBranch: "", gitlabBuildPath: "", gitlabId: "", gitlabRepository: "", gitlabOwner: "", command: null, cpuLimit: null, cpuReservation: null, createdAt: "", customGitBranch: "", customGitBuildPath: "", customGitSSHKeyId: null, customGitUrl: "", description: "", dockerfile: null, dockerImage: null, dropBuildPath: null, enabled: null, env: null, healthCheckSwarm: null, labelsSwarm: null, memoryLimit: null, memoryReservation: null, modeSwarm: null, mounts: [], name: "", networkSwarm: null, owner: null, password: null, placementSwarm: null, ports: [], publishDirectory: null, isStaticSpa: null, redirects: [], refreshToken: "", registry: null, registryId: null, replicas: 1, repository: null, restartPolicySwarm: null, rollbackConfigSwarm: null, security: [], sourceType: "git", subtitle: null, title: null, updateConfigSwarm: null, username: null, dockerContextPath: null, stopGracePeriodSwarm: null, ulimitsSwarm: null, }; const baseDomain: Domain = { applicationId: "", certificateType: "none", createdAt: "", domainId: "", host: "", https: false, path: null, port: null, serviceName: "", composeId: "", customCertResolver: null, domainType: "application", uniqueConfigKey: 1, previewDeploymentId: "", internalPath: "/", stripPath: false, }; const baseRedirect: Redirect = { redirectId: "", regex: "", replacement: "", permanent: false, uniqueConfigKey: 1, createdAt: "", applicationId: "", }; /** Middlewares */ test("Web entrypoint on http domain", async () => { const router = await createRouterConfig( baseApp, { ...baseDomain, https: false }, "web", ); expect(router.middlewares).not.toContain("redirect-to-https"); expect(router.rule).not.toContain("PathPrefix"); }); test("Web entrypoint on http domain with custom path", async () => { const router = await createRouterConfig( baseApp, { ...baseDomain, path: "/foo", https: false }, "web", ); expect(router.rule).toContain("PathPrefix(`/foo`)"); }); test("Web entrypoint on http domain with redirect", async () => { const router = await createRouterConfig( { ...baseApp, appName: "test", redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }], }, { ...baseDomain, https: false }, "web", ); expect(router.middlewares).not.toContain("redirect-to-https"); expect(router.middlewares).toContain("redirect-test-1"); }); test("Web entrypoint on http domain with multiple redirect", async () => { const router = await createRouterConfig( { ...baseApp, appName: "test", redirects: [ { ...baseRedirect, uniqueConfigKey: 1 }, { ...baseRedirect, uniqueConfigKey: 2 }, ], }, { ...baseDomain, https: false }, "web", ); expect(router.middlewares).not.toContain("redirect-to-https"); expect(router.middlewares).toContain("redirect-test-1"); expect(router.middlewares).toContain("redirect-test-2"); }); test("Web entrypoint on https domain", async () => { const router = await createRouterConfig( baseApp, { ...baseDomain, https: true }, "web", ); expect(router.middlewares).toContain("redirect-to-https"); }); test("Web entrypoint on https domain with redirect", async () => { const router = await createRouterConfig( { ...baseApp, appName: "test", redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }], }, { ...baseDomain, https: true }, "web", ); expect(router.middlewares).toContain("redirect-to-https"); expect(router.middlewares).not.toContain("redirect-test-1"); }); test("Websecure entrypoint on https domain", async () => { const router = await createRouterConfig( baseApp, { ...baseDomain, https: true }, "websecure", ); expect(router.middlewares).not.toContain("redirect-to-https"); }); test("Websecure entrypoint on https domain with redirect", async () => { const router = await createRouterConfig( { ...baseApp, appName: "test", redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }], }, { ...baseDomain, https: true }, "websecure", ); expect(router.middlewares).not.toContain("redirect-to-https"); expect(router.middlewares).toContain("redirect-test-1"); }); /** Certificates */ test("CertificateType on websecure entrypoint", async () => { const router = await createRouterConfig( baseApp, { ...baseDomain, certificateType: "letsencrypt" }, "websecure", ); expect(router.tls?.certResolver).toBe("letsencrypt"); }); /** IDN/Punycode */ test("Internationalized domain name is converted to punycode", async () => { const router = await createRouterConfig( baseApp, { ...baseDomain, host: "тест.рф" }, "web", ); // тест.рф in punycode is xn--e1aybc.xn--p1ai expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)"); expect(router.rule).not.toContain("тест.рф"); }); test("ASCII domain remains unchanged", async () => { const router = await createRouterConfig( baseApp, { ...baseDomain, host: "example.com" }, "web", ); expect(router.rule).toContain("Host(`example.com`)"); }); test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => { const router = await createRouterConfig( baseApp, { ...baseDomain, host: "сайт.ru" }, "web", ); // сайт in punycode is xn--80aswg expect(router.rule).toContain("Host(`xn--80aswg.ru`)"); expect(router.rule).not.toContain("сайт"); }); test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => { const router = await createRouterConfig( baseApp, { ...baseDomain, host: "app.тест.рф" }, "web", ); // app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)"); expect(router.rule).not.toContain("тест.рф"); }); ================================================ FILE: apps/dokploy/__test__/utils/backups.test.ts ================================================ import { normalizeS3Path } from "@dokploy/server/utils/backups/utils"; import { describe, expect, test } from "vitest"; describe("normalizeS3Path", () => { test("should handle empty and whitespace-only prefix", () => { expect(normalizeS3Path("")).toBe(""); expect(normalizeS3Path("/")).toBe(""); expect(normalizeS3Path(" ")).toBe(""); expect(normalizeS3Path("\t")).toBe(""); expect(normalizeS3Path("\n")).toBe(""); expect(normalizeS3Path(" \n \t ")).toBe(""); }); test("should trim whitespace from prefix", () => { expect(normalizeS3Path(" prefix")).toBe("prefix/"); expect(normalizeS3Path("prefix ")).toBe("prefix/"); expect(normalizeS3Path(" prefix ")).toBe("prefix/"); expect(normalizeS3Path("\tprefix\t")).toBe("prefix/"); expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/"); }); test("should remove leading slashes", () => { expect(normalizeS3Path("/prefix")).toBe("prefix/"); expect(normalizeS3Path("///prefix")).toBe("prefix/"); }); test("should remove trailing slashes", () => { expect(normalizeS3Path("prefix/")).toBe("prefix/"); expect(normalizeS3Path("prefix///")).toBe("prefix/"); }); test("should remove both leading and trailing slashes", () => { expect(normalizeS3Path("/prefix/")).toBe("prefix/"); expect(normalizeS3Path("///prefix///")).toBe("prefix/"); }); test("should handle nested paths", () => { expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/"); expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/"); expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/"); }); test("should preserve middle slashes", () => { expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/"); expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/"); }); test("should handle special characters", () => { expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/"); expect(normalizeS3Path("prefix_with_underscores")).toBe( "prefix_with_underscores/", ); expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/"); }); test("should handle the cases from the bug report", () => { expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/"); expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/"); expect(normalizeS3Path("instance-backups")).toBe("instance-backups/"); }); }); ================================================ FILE: apps/dokploy/__test__/vitest.config.ts ================================================ import path from "node:path"; import tsconfigPaths from "vite-tsconfig-paths"; import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__ exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"], pool: "forks", setupFiles: [path.resolve(__dirname, "setup.ts")], }, define: { "process.env": { NODE: "test", GITHUB_CLIENT_ID: "test", GITHUB_CLIENT_SECRET: "test", GOOGLE_CLIENT_ID: "test", GOOGLE_CLIENT_SECRET: "test", }, }, plugins: [ tsconfigPaths({ projects: [path.resolve(__dirname, "../tsconfig.json")], }), ], resolve: { alias: { "@dokploy/server": path.resolve( __dirname, "../../../packages/server/src", ), }, }, }); ================================================ FILE: apps/dokploy/__test__/wss/readValidDirectory.test.ts ================================================ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; const BASE = "/base"; vi.mock("@dokploy/server/constants", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, paths: () => ({ ...actual.paths(), BASE_PATH: BASE, LOGS_PATH: `${BASE}/logs`, APPLICATIONS_PATH: `${BASE}/applications`, }), }; }); // Import after mock so paths() uses our BASE const { readValidDirectory } = await import("@dokploy/server"); describe("readValidDirectory (path traversal)", () => { it("returns true when directory is exactly BASE_PATH", () => { expect(readValidDirectory(BASE)).toBe(true); expect(readValidDirectory(path.resolve(BASE))).toBe(true); }); it("returns true when directory is under BASE_PATH", () => { expect(readValidDirectory(`${BASE}/logs`)).toBe(true); expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true); expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true); }); it("returns false for path traversal escaping base (absolute)", () => { expect(readValidDirectory("/etc/passwd")).toBe(false); expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false); expect(readValidDirectory("/tmp/outside")).toBe(false); }); it("returns false when resolved path escapes base via ..", () => { // Resolved: /etc/passwd (outside /base) expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false); expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false); expect(readValidDirectory(`${BASE}/..`)).toBe(false); }); it("returns true when .. stays within base", () => { // e.g. /base/logs/../applications -> /base/applications (still under /base) expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true); expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true); }); it("accepts serverId for remote base path", () => { // With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw expect(readValidDirectory(BASE, "server-1")).toBe(true); expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false); }); it("returns false for null/undefined-like paths that resolve outside", () => { // Paths that might resolve to cwd or root expect(readValidDirectory(".")).toBe(false); expect(readValidDirectory("..")).toBe(false); }); it("returns true for BASE_PATH with trailing slash or double slashes under base", () => { expect(readValidDirectory(`${BASE}/`)).toBe(true); expect(readValidDirectory(`${BASE}//logs`)).toBe(true); expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true); }); it("returns false when path looks like base but is a sibling or prefix", () => { expect(readValidDirectory("/base-evil")).toBe(false); expect(readValidDirectory("/bas")).toBe(false); expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false); }); it("returns false for empty string (resolves to cwd)", () => { expect(readValidDirectory("")).toBe(false); }); }); ================================================ FILE: apps/dokploy/__test__/wss/utils.test.ts ================================================ import { describe, expect, it } from "vitest"; import { isValidContainerId, isValidSearch, isValidSince, isValidTail, } from "../../server/wss/utils"; describe("isValidTail (docker-container-logs)", () => { it("accepts valid numeric tail values", () => { expect(isValidTail("0")).toBe(true); expect(isValidTail("1")).toBe(true); expect(isValidTail("100")).toBe(true); expect(isValidTail("10000")).toBe(true); }); it("rejects tail above 10000", () => { expect(isValidTail("10001")).toBe(false); expect(isValidTail("99999")).toBe(false); }); it("rejects non-numeric tail", () => { expect(isValidTail("")).toBe(false); expect(isValidTail("abc")).toBe(false); expect(isValidTail("10a")).toBe(false); expect(isValidTail("-1")).toBe(false); }); it("rejects command injection payloads in tail", () => { expect(isValidTail("10; whoami; #")).toBe(false); expect(isValidTail("100 | cat /etc/passwd")).toBe(false); expect(isValidTail("$(id)")).toBe(false); expect(isValidTail("`id`")).toBe(false); expect(isValidTail("100\nid")).toBe(false); expect(isValidTail("100 && id")).toBe(false); expect(isValidTail("100; env | grep DATABASE")).toBe(false); }); }); describe("isValidSince (docker-container-logs)", () => { it("accepts 'all'", () => { expect(isValidSince("all")).toBe(true); }); it("accepts valid duration format (number + s|m|h|d)", () => { expect(isValidSince("5s")).toBe(true); expect(isValidSince("10m")).toBe(true); expect(isValidSince("1h")).toBe(true); expect(isValidSince("2d")).toBe(true); expect(isValidSince("0s")).toBe(true); expect(isValidSince("999d")).toBe(true); }); it("rejects invalid duration format", () => { expect(isValidSince("")).toBe(false); expect(isValidSince("5")).toBe(false); expect(isValidSince("s")).toBe(false); expect(isValidSince("5x")).toBe(false); expect(isValidSince("5sec")).toBe(false); expect(isValidSince("5 m")).toBe(false); }); it("rejects command injection payloads in since", () => { expect(isValidSince("5s; whoami")).toBe(false); expect(isValidSince("all; id")).toBe(false); expect(isValidSince("1m$(id)")).toBe(false); expect(isValidSince("1m | cat /etc/passwd")).toBe(false); }); }); describe("isValidSearch (docker-container-logs)", () => { it("accepts empty string", () => { expect(isValidSearch("")).toBe(true); }); it("accepts only alphanumeric, space, dot, underscore, hyphen", () => { expect(isValidSearch("error")).toBe(true); expect(isValidSearch("foo bar")).toBe(true); expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true); expect(isValidSearch("")).toBe(true); }); it("rejects strings longer than 500 chars", () => { expect(isValidSearch("a".repeat(501))).toBe(false); expect(isValidSearch("a".repeat(500))).toBe(true); }); it("rejects control characters and non-printable", () => { expect(isValidSearch("foo\nbar")).toBe(false); expect(isValidSearch("foo\rbar")).toBe(false); expect(isValidSearch("\x00")).toBe(false); expect(isValidSearch("a\x19b")).toBe(false); }); it("rejects command injection vectors in search (search is concatenated into shell)", () => { // Double-quoted context (SSH line 99): $ and ` execute expect(isValidSearch("$(whoami)")).toBe(false); expect(isValidSearch("`id`")).toBe(false); expect(isValidSearch("$(id)")).toBe(false); // Single-quoted context (local line 153): ' breaks out expect(isValidSearch("'$(whoami)'")).toBe(false); expect(isValidSearch("error'")).toBe(false); expect(isValidSearch("'; whoami; #")).toBe(false); // Other shell-metacharacters expect(isValidSearch("error; id")).toBe(false); expect(isValidSearch("a|b")).toBe(false); expect(isValidSearch('error"')).toBe(false); expect(isValidSearch("a&b")).toBe(false); }); }); describe("isValidContainerId (docker-container-logs)", () => { it("accepts valid hex container IDs", () => { expect(isValidContainerId("a".repeat(12))).toBe(true); expect(isValidContainerId("abc123def456")).toBe(true); expect(isValidContainerId("a".repeat(64))).toBe(true); }); it("accepts valid container names", () => { expect(isValidContainerId("my-container")).toBe(true); expect(isValidContainerId("app_1")).toBe(true); expect(isValidContainerId("service.name")).toBe(true); }); it("rejects command injection in container ID", () => { expect(isValidContainerId("dummy; whoami")).toBe(false); expect(isValidContainerId("$(id)")).toBe(false); expect(isValidContainerId("`id`")).toBe(false); expect(isValidContainerId("container|cat /etc/passwd")).toBe(false); expect(isValidContainerId("x; env | grep DATABASE")).toBe(false); }); }); ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx ================================================ import { Settings } from "lucide-react"; import { useState } from "react"; import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { EndpointSpecForm, HealthCheckForm, LabelsForm, ModeForm, NetworkForm, PlacementForm, RestartPolicyForm, RollbackConfigForm, StopGracePeriodForm, UpdateConfigForm, } from "./swarm-forms"; type MenuItem = { id: string; label: string; description: string; docDescription: string; }; 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: "network", label: "Network", description: "Configure network attachments", docDescription: "Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.", }, { 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, ): value is { stopGracePeriodSwarm: bigint | number | string | null } => typeof value === "object" && value !== null && "stopGracePeriodSwarm" in value; interface Props { id: string; type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; } export const AddSwarmSettings = ({ id, type }: Props) => { const [activeMenu, setActiveMenu] = useState("health-check"); const [open, setOpen] = useState(false); return ( Swarm Settings Configure swarm settings for your service.
Changing settings such as placements may cause the logs/monitoring, backups and other features to be unavailable.
{/* Left Column - Menu */}
{/* Right Column - Form */}
{activeMenu === "health-check" && ( )} {activeMenu === "restart-policy" && ( )} {activeMenu === "placement" && ( )} {activeMenu === "update-config" && ( )} {activeMenu === "rollback-config" && ( )} {activeMenu === "mode" && } {activeMenu === "network" && } {activeMenu === "labels" && } {activeMenu === "stop-grace-period" && ( )} {activeMenu === "endpoint-spec" && ( )}
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { api } from "@/utils/api"; import { AddSwarmSettings } from "./modify-swarm-settings"; interface Props { id: string; type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; } const AddRedirectchema = z.object({ replicas: z.number().min(1, "Replicas must be at least 1"), registryId: z.string().optional(), }); type AddCommand = z.infer; export const ShowClusterSettings = ({ 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 { data: registries } = api.registry.all.useQuery(); 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, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.update.useMutation(); const form = useForm({ defaultValues: { ...(type === "application" && data && "registryId" in data ? { registryId: data?.registryId || "", } : {}), replicas: data?.replicas || 1, }, resolver: zodResolver(AddRedirectchema), }); useEffect(() => { if (data?.command) { form.reset({ ...(type === "application" && data && "registryId" in data ? { registryId: data?.registryId || "", } : {}), replicas: data?.replicas || 1, }); } }, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]); const onSubmit = async (data: AddCommand) => { await mutateAsync({ applicationId: id || "", postgresId: id || "", redisId: id || "", mysqlId: id || "", mariadbId: id || "", mongoId: id || "", ...(type === "application" ? { registryId: data?.registryId === "none" || !data?.registryId ? null : data?.registryId, } : {}), replicas: data?.replicas, }) .then(async () => { toast.success("Command Updated"); await refetch(); }) .catch(() => { toast.error("Error updating the command"); }); }; return (
Cluster Settings Modify swarm settings for the service.
Please remember to click Redeploy after modify the cluster settings to apply the changes.
( Replicas { const value = e.target.value; field.onChange(value === "" ? 0 : Number(value)); }} type="number" value={field.value || ""} /> )} />
{type === "application" && ( <> {registries && registries?.length === 0 ? (
To use a cluster feature, you need to configure at least a registry first. Please, go to{" "} Settings {" "} to do so.
) : ( <> ( Select a registry )} /> )} )}
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 (
( Mode Endpoint mode (vip or dnsrr) )} />
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 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, }, }); const testCommands = form.watch("Test") || []; 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, }); } }, [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 = () => { form.setValue("Test", [...testCommands, ""]); }; const updateTestCommand = (index: number, value: string) => { const newCommands = [...testCommands]; newCommands[index] = value; form.setValue("Test", newCommands); }; const removeTestCommand = (index: number) => { form.setValue( "Test", testCommands.filter((_: string, i: number) => i !== index), ); }; return (
Test Commands Command to run for health check (e.g., ["CMD-SHELL", "curl -f http://localhost:3000/health"])
{testCommands.map((cmd: string, index: number) => (
updateTestCommand(index, e.target.value)} placeholder={ index === 0 ? "CMD-SHELL" : "curl -f http://localhost:3000/health" } />
))}
( Interval (nanoseconds) Time between health checks (e.g., 10000000000 for 10 seconds) )} /> ( Timeout (nanoseconds) Maximum time to wait for health check response )} /> ( Start Period (nanoseconds) Initial grace period before health checks begin )} /> ( Retries Number of consecutive failures needed to consider container unhealthy )} />
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts ================================================ export { EndpointSpecForm } from "./endpoint-spec-form"; export { HealthCheckForm } from "./health-check-form"; export { LabelsForm } from "./labels-form"; export { ModeForm } from "./mode-form"; export { NetworkForm } from "./network-form"; export { PlacementForm } from "./placement-form"; export { RestartPolicyForm } from "./restart-policy-form"; export { RollbackConfigForm } from "./rollback-config-form"; export { StopGracePeriodForm } from "./stop-grace-period-form"; export { UpdateConfigForm } from "./update-config-form"; export { filterEmptyValues, hasValues } from "./utils"; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 (
Labels Add key-value labels to your service
{fields.map((field, index) => (
( )} /> ( )} />
))}
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx ================================================ 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 !== undefined && formData.Replicas !== "" ? Number(formData.Replicas) : undefined, }, } : { 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 (
( Mode Type Choose between replicated or global service mode )} /> {modeType === "Replicated" && ( ( Replicas Number of replicas to run )} /> )}
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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"; const driverOptEntrySchema = z.object({ key: z.string(), value: z.string(), }); export const networkFormSchema = z.object({ networks: z .array( z.object({ Target: z.string().optional(), Aliases: z.string().optional(), DriverOptsEntries: z.array(driverOptEntrySchema).optional(), }), ) .optional(), }); interface NetworkFormProps { id: string; type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; } export const NetworkForm = ({ id, type }: NetworkFormProps) => { 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(networkFormSchema), defaultValues: { networks: [], }, }); const { fields, append, remove } = useFieldArray({ control: form.control, name: "networks", }); useEffect(() => { if (data?.networkSwarm && Array.isArray(data.networkSwarm)) { const networkEntries = data.networkSwarm.map((network) => ({ Target: network.Target || "", Aliases: network.Aliases?.join(", ") || "", DriverOptsEntries: network.DriverOpts ? Object.entries(network.DriverOpts).map(([key, value]) => ({ key, value: value ?? "", })) : [], })); form.reset({ networks: networkEntries }); } }, [data, form]); const onSubmit = async (formData: z.infer) => { setIsLoading(true); try { const networksArray = formData.networks ?.filter((network) => network.Target) .map((network) => { const entries = (network.DriverOptsEntries ?? []).filter( (e) => e.key.trim() !== "", ); const driverOpts = entries.length > 0 ? Object.fromEntries( entries.map((e) => [e.key.trim(), e.value]), ) : undefined; return { Target: network.Target, Aliases: network.Aliases ? network.Aliases.split(",").map((alias) => alias.trim()) : undefined, DriverOpts: driverOpts, }; }) || []; // If no networks, send null to clear the database const networksToSend = networksArray.length > 0 ? networksArray : null; await mutateAsync({ applicationId: id || "", postgresId: id || "", redisId: id || "", mysqlId: id || "", mariadbId: id || "", mongoId: id || "", networkSwarm: networksToSend, }); toast.success("Network configuration updated successfully"); refetch(); } catch { toast.error("Error updating network configuration"); } finally { setIsLoading(false); } }; return (
Networks Configure network attachments for your service
{fields.map((field, index) => (
( Network Name The name of the network to attach to )} /> ( Aliases (optional) Comma-separated list of network aliases )} />
Driver options (optional) e.g. com.docker.network.driver.mtu, com.docker.network.driver.host_binding {( form.watch(`networks.${index}.DriverOptsEntries`) ?? [] ).map((_, optIndex) => (
( )} /> ( )} />
))}
))}
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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({ 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, Preferences: formData.Preferences?.map((p) => ({ Spread: { SpreadDescriptor: p.SpreadDescriptor }, })), } : 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 (
Constraints Placement constraints (e.g., "node.role==manager")
{constraints.map((constraint: string, index: number) => (
updateConstraint(index, e.target.value)} placeholder="node.role==manager" />
))}
Preferences Spread preferences for task distribution (e.g., "node.labels.region")
{preferences.map((pref: any, index: number) => (
updatePreference(index, e.target.value)} placeholder="node.labels.region" />
))}
( Max Replicas Maximum number of replicas per node )} />
Platforms Target platforms for task scheduling
{platforms.map((platform: any, index: number) => (
updatePlatform(index, "Architecture", e.target.value) } placeholder="amd64" /> updatePlatform(index, "OS", e.target.value)} placeholder="linux" />
))}
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 (
( Condition When to restart the container )} /> ( Delay (nanoseconds) Wait time between restart attempts )} /> ( Max Attempts Maximum number of restart attempts )} /> ( Window (nanoseconds) Time window to evaluate restart policy )} />
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 (
( Parallelism Number of tasks to rollback simultaneously )} /> ( Delay (nanoseconds) Delay between task rollbacks )} /> ( Failure Action Action on rollback failure )} /> ( Monitor (nanoseconds) Duration to monitor for failure after rollback )} /> ( Max Failure Ratio Maximum failure ratio tolerated (0-1) )} /> ( Order Rollback order strategy )} />
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx ================================================ 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 (
( Stop Grace Period (nanoseconds) Time to wait before forcefully killing the container
Examples: 30000000000 (30s), 120000000000 (2m)
field.onChange( e.target.value ? BigInt(e.target.value) : null, ) } />
)} />
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 (
( Parallelism Number of tasks to update simultaneously )} /> ( Delay (nanoseconds) Delay between task updates )} /> ( Failure Action Action on update failure )} /> ( Monitor (nanoseconds) Duration to monitor for failure after update )} /> ( Max Failure Ratio Maximum failure ratio tolerated (0-1) )} /> ( Order Update order strategy )} />
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts ================================================ /** * 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; }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Plus, Trash2 } from "lucide-react"; import { useEffect } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; interface Props { applicationId: string; } 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; export const AddCommand = ({ applicationId }: Props) => { const { data } = api.application.one.useQuery( { applicationId, }, { enabled: !!applicationId }, ); const utils = api.useUtils(); const { mutateAsync, isPending } = api.application.update.useMutation(); const form = useForm({ defaultValues: { command: "", args: [], }, resolver: zodResolver(AddRedirectSchema), }); const { fields, append, remove } = useFieldArray({ control: form.control, name: "args", }); useEffect(() => { if (data) { form.reset({ command: data?.command || "", args: data?.args?.map((arg) => ({ value: arg })) || [], }); } }, [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"); await utils.application.one.invalidate({ applicationId, }); }) .catch(() => { toast.error("Error updating the command"); }); }; return (
Run Command Run a custom command in the container after the application initialized
( Command )} />
Arguments (Args)
{fields.length === 0 && (

No arguments added yet. Click "Add Argument" to add one.

)} {fields.map((field, index) => ( (
)} /> ))}
); }; ================================================ FILE: apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx ================================================ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Code2, Globe2, HardDrive } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/utils/api"; const ImportSchema = z.object({ base64: z.string(), }); type ImportType = z.infer; interface Props { composeId: string; } export const ShowImport = ({ composeId }: Props) => { const [showModal, setShowModal] = useState(false); const [showMountContent, setShowMountContent] = useState(false); const [selectedMount, setSelectedMount] = useState<{ filePath: string; content: string; } | null>(null); const [templateInfo, setTemplateInfo] = useState<{ compose: string; template: { domains: Array<{ serviceName: string; port: number; path?: string; host?: string; }>; envs: string[]; mounts: Array<{ filePath: string; content: string; }>; }; } | null>(null); const utils = api.useUtils(); const { mutateAsync: processTemplate, isPending: isLoadingTemplate } = api.compose.processTemplate.useMutation(); const { mutateAsync: importTemplate, isPending: isImporting, isSuccess: isImportSuccess, } = api.compose.import.useMutation(); const form = useForm({ defaultValues: { base64: "", }, resolver: zodResolver(ImportSchema), }); useEffect(() => { form.reset({ base64: "", }); }, [isImportSuccess]); const onSubmit = async () => { const base64 = form.getValues("base64"); if (!base64) { toast.error("Please enter a base64 template"); return; } try { await importTemplate({ composeId, base64, }); toast.success("Template imported successfully"); await utils.compose.one.invalidate({ composeId, }); setShowModal(false); } catch { toast.error("Error importing template"); } }; const handleLoadTemplate = async () => { const base64 = form.getValues("base64"); if (!base64) { toast.error("Please enter a base64 template"); return; } try { const result = await processTemplate({ composeId, base64, }); setTemplateInfo(result); setShowModal(true); } catch { toast.error("Error processing template"); } }; const handleShowMountContent = (mount: { filePath: string; content: string; }) => { setSelectedMount(mount); setShowMountContent(true); }; return ( <> Import Import your Template configuration Warning: Importing a template will remove all existing environment variables, mounts, and domains from this service.
( Configuration (Base64)