From 36ba9fcb3e8152b067cdc178f3993fdaef793a21 Mon Sep 17 00:00:00 2001 From: Laode Muhammad Al Fatih Date: Fri, 10 Apr 2026 16:18:37 +0700 Subject: [PATCH 1/8] feat: add "Deploy with Fresh Volumes" button for Docker Compose projects Adds a one-click option to redeploy Docker Compose services with clean volumes, removing the need to manually edit the run command. When triggered, it runs `docker compose down --volumes` before the standard deploy build step. The feature is only available for docker-compose type projects (not swarm stacks). --- .../dashboard/compose/general/actions.tsx | 51 ++++++++++++++++++- apps/dokploy/server/api/routers/compose.ts | 2 + .../server/queues/deployments-queue.ts | 2 + apps/dokploy/server/queues/queue-types.ts | 1 + packages/server/src/db/schema/compose.ts | 2 + packages/server/src/services/compose.ts | 24 +++++++++ 6 files changed, 81 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx index d04725e26..71c703315 100644 --- a/apps/dokploy/components/dashboard/compose/general/actions.tsx +++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx @@ -1,5 +1,5 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react"; +import { Ban, CheckCircle2, HardDriveDownload, RefreshCcw, Rocket, Terminal } from "lucide-react"; import { useRouter } from "next/router"; import { toast } from "sonner"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -82,6 +82,55 @@ export const ComposeActions = ({ composeId }: Props) => { )} + {canDeploy && + data?.composeType === "docker-compose" && ( + { + await deploy({ + composeId: composeId, + freshVolumes: true, + }) + .then(() => { + toast.success( + "Compose deployed with fresh volumes", + ); + refetch(); + router.push( + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, + ); + }) + .catch(() => { + toast.error("Error deploying compose"); + }); + }} + > + + + )} {canDeploy && ( composeId: job.data.composeId, titleLog: job.data.titleLog, descriptionLog: job.data.descriptionLog, + freshVolumes: job.data.freshVolumes, }); } else if (job.data.type === "redeploy") { await rebuildCompose({ composeId: job.data.composeId, titleLog: job.data.titleLog, descriptionLog: job.data.descriptionLog, + freshVolumes: job.data.freshVolumes, }); } } else if (job.data.applicationType === "application-preview") { diff --git a/apps/dokploy/server/queues/queue-types.ts b/apps/dokploy/server/queues/queue-types.ts index 1000725ad..5c469dd9e 100644 --- a/apps/dokploy/server/queues/queue-types.ts +++ b/apps/dokploy/server/queues/queue-types.ts @@ -16,6 +16,7 @@ type DeployJob = type: "deploy" | "redeploy"; applicationType: "compose"; serverId?: string; + freshVolumes?: boolean; } | { applicationId: string; diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 7803cb0a7..0416e5ea3 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -198,12 +198,14 @@ export const apiDeployCompose = z.object({ composeId: z.string().min(1), title: z.string().optional(), description: z.string().optional(), + freshVolumes: z.boolean().optional(), }); export const apiRedeployCompose = z.object({ composeId: z.string().min(1), title: z.string().optional(), description: z.string().optional(), + freshVolumes: z.boolean().optional(), }); export const apiDeleteCompose = z.object({ diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 0cec3418b..d78e63a41 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -209,10 +209,12 @@ export const deployCompose = async ({ composeId, titleLog = "Manual deployment", descriptionLog = "", + freshVolumes = false, }: { composeId: string; titleLog: string; descriptionLog: string; + freshVolumes?: boolean; }) => { const compose = await findComposeById(composeId); @@ -266,6 +268,16 @@ export const deployCompose = async ({ } } + if (freshVolumes && compose.composeType === "docker-compose") { + const downCommand = `set -e; docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; + const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`; + if (compose.serverId) { + await execAsyncRemote(compose.serverId, downWithLog); + } else { + await execAsync(downWithLog); + } + } + command = "set -e;"; command += await getBuildComposeCommand(entity); commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; @@ -339,10 +351,12 @@ export const rebuildCompose = async ({ composeId, titleLog = "Rebuild deployment", descriptionLog = "", + freshVolumes = false, }: { composeId: string; titleLog: string; descriptionLog: string; + freshVolumes?: boolean; }) => { const compose = await findComposeById(composeId); @@ -380,6 +394,16 @@ export const rebuildCompose = async ({ } } + if (freshVolumes && compose.composeType === "docker-compose") { + const downCommand = `set -e; docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; + const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`; + if (compose.serverId) { + await execAsyncRemote(compose.serverId, downWithLog); + } else { + await execAsync(downWithLog); + } + } + command = "set -e;"; command += await getBuildComposeCommand(compose); commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; From c668f6c7bd75cf5ef717193a827538897512e08e Mon Sep 17 00:00:00 2001 From: Laode Muhammad Al Fatih Date: Fri, 10 Apr 2026 16:26:24 +0700 Subject: [PATCH 2/8] fix: add env -i PATH prefix to fresh volumes down command Consistent with the rest of the codebase where every docker compose invocation uses `env -i PATH="$PATH"` for a clean environment. --- packages/server/src/services/compose.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index d78e63a41..2ee0e0fd6 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -269,7 +269,7 @@ export const deployCompose = async ({ } if (freshVolumes && compose.composeType === "docker-compose") { - const downCommand = `set -e; docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; + const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`; if (compose.serverId) { await execAsyncRemote(compose.serverId, downWithLog); @@ -395,7 +395,7 @@ export const rebuildCompose = async ({ } if (freshVolumes && compose.composeType === "docker-compose") { - const downCommand = `set -e; docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; + const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`; if (compose.serverId) { await execAsyncRemote(compose.serverId, downWithLog); From f1e2467bb9454f1689431022b9da968f92413748 Mon Sep 17 00:00:00 2001 From: drago1520 <141066422+drago1520@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:39:00 +0300 Subject: [PATCH 3/8] fix/docker-context-path-default: Placeholder with name attribute "dockerContextPath" lied that the default path is ".", while being the Dockerfile directory. --- packages/server/src/utils/builders/docker-file.ts | 6 +----- packages/server/src/utils/filesystem/directory.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/server/src/utils/builders/docker-file.ts b/packages/server/src/utils/builders/docker-file.ts index 5f315cd58..b02156ee8 100644 --- a/packages/server/src/utils/builders/docker-file.ts +++ b/packages/server/src/utils/builders/docker-file.ts @@ -26,11 +26,7 @@ export const getDockerCommand = (application: ApplicationNested) => { try { const image = `${appName}`; - const defaultContextPath = - dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || "."; - - const dockerContextPath = - getDockerContextPath(application) || defaultContextPath; + const dockerContextPath = getDockerContextPath(application); const commandArgs = ["build", "-t", image, "-f", dockerFilePath, "."]; diff --git a/packages/server/src/utils/filesystem/directory.ts b/packages/server/src/utils/filesystem/directory.ts index 3713ef575..c5e354ac8 100644 --- a/packages/server/src/utils/filesystem/directory.ts +++ b/packages/server/src/utils/filesystem/directory.ts @@ -138,8 +138,10 @@ export const getDockerContextPath = (application: Application) => { const { APPLICATIONS_PATH } = paths(!!application.serverId); const { appName, dockerContextPath } = application; - if (!dockerContextPath) { - return null; - } - return path.join(APPLICATIONS_PATH, appName, "code", dockerContextPath); + return path.join( + APPLICATIONS_PATH, + appName, + "code", + dockerContextPath || ".", + ); }; From a424f6437053eb1dddaa887dfdab83f1440047bd Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 1 Sep 2026 19:11:27 -0600 Subject: [PATCH 4/8] chore(api): add local Inngest dev server script --- apps/api/README.md | 10 ++++++++++ apps/api/package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/apps/api/README.md b/apps/api/README.md index e12b31db7..3bd3f9783 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -6,3 +6,13 @@ npm run dev ``` open http://localhost:3000 ``` + +## Inngest + +This service uses Inngest to queue deployments. Run the local dev server in a separate terminal: + +``` +npm run dev:inngest +``` + +It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`. diff --git a/apps/api/package.json b/apps/api/package.json index cd1689041..119c2b3f1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "PORT=4000 tsx watch src/index.ts", + "dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery", "build": "rimraf dist && tsc --project tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc --noEmit" From 2e2e0c8c29120f7bc7c236631e41d6c1f8cb0014 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 1 Sep 2026 19:11:39 -0600 Subject: [PATCH 5/8] feat: cloud onboarding wizard, billing trial card, and post-checkout server setup - Onboarding wizard (Welcome -> Plan -> Project -> Server -> Deploy -> Complete), shown once to an org owner with zero projects and no active plan/trial; skippable per step or entirely - Billing page shows the org's current plan, and a no-card 14-day trial card when eligible - Post-checkout "Welcome to Dokploy Cloud" modal simplified to reuse the onboarding wizard's own project/server/deploy steps behind a modal instead of its previous standalone 6-step flow, using the app's regular typography instead of the wizard's display serif - Onboarding wizard validates a persisted project still exists before resuming a stale session, and the dashboard layout no longer gets stuck redirecting to /dashboard/home once the local onboarding-active flag goes stale mid-session - onboardingCompletedAt column on user, with a backfill so existing users aren't shown the wizard - pnpm reset-onboarding dev script to reset a test account's onboarding state end to end --- .../components/dashboard/onboarding/font.ts | 8 + .../dashboard/onboarding/onboarding-lock.ts | 49 + .../onboarding/onboarding-wizard.tsx | 295 + .../onboarding/steps/complete-step.tsx | 133 + .../onboarding/steps/deploy-step.tsx | 295 + .../dashboard/onboarding/steps/plan-step.tsx | 207 + .../onboarding/steps/project-step.tsx | 118 + .../onboarding/steps/server-step.tsx | 346 + .../onboarding/steps/welcome-step.tsx | 69 + .../settings/billing/show-billing.tsx | 108 + .../settings/billing/show-welcome-dokploy.tsx | 61 - .../settings/servers/show-health-modal.tsx | 28 - .../settings/servers/show-servers.tsx | 19 - .../servers/welcome-stripe/create-server.tsx | 281 - .../servers/welcome-stripe/create-ssh-key.tsx | 190 - .../settings/servers/welcome-stripe/setup.tsx | 136 - .../servers/welcome-stripe/verify.tsx | 179 - .../welcome-stripe/welcome-subscription.tsx | 646 +- .../components/layouts/dashboard-layout.tsx | 18 + .../drizzle/0190_nappy_anita_blake.sql | 7 + apps/dokploy/drizzle/meta/0190_snapshot.json | 9153 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + apps/dokploy/package.json | 3 +- apps/dokploy/pages/dashboard/home.tsx | 36 + apps/dokploy/pages/dashboard/projects.tsx | 19 +- apps/dokploy/scripts/reset-onboarding.ts | 119 + .../dokploy/server/api/routers/application.ts | 114 + apps/dokploy/server/api/routers/project.ts | 31 + apps/dokploy/server/api/routers/server.ts | 17 +- apps/dokploy/server/api/routers/stripe.ts | 81 +- apps/dokploy/server/utils/billing.ts | 124 +- packages/server/src/db/schema/user.ts | 2 + 32 files changed, 11546 insertions(+), 1353 deletions(-) create mode 100644 apps/dokploy/components/dashboard/onboarding/font.ts create mode 100644 apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts create mode 100644 apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx create mode 100644 apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx create mode 100644 apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx create mode 100644 apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx create mode 100644 apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx create mode 100644 apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx create mode 100644 apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx delete mode 100644 apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx delete mode 100644 apps/dokploy/components/dashboard/settings/servers/show-health-modal.tsx delete mode 100644 apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx delete mode 100644 apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx delete mode 100644 apps/dokploy/components/dashboard/settings/servers/welcome-stripe/setup.tsx delete mode 100644 apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx create mode 100644 apps/dokploy/drizzle/0190_nappy_anita_blake.sql create mode 100644 apps/dokploy/drizzle/meta/0190_snapshot.json create mode 100644 apps/dokploy/scripts/reset-onboarding.ts diff --git a/apps/dokploy/components/dashboard/onboarding/font.ts b/apps/dokploy/components/dashboard/onboarding/font.ts new file mode 100644 index 000000000..7f8021e06 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/font.ts @@ -0,0 +1,8 @@ +import { Fraunces } from "next/font/google"; + +export const displayFont = Fraunces({ + subsets: ["latin"], + weight: ["600"], + style: ["normal", "italic"], + display: "swap", +}); diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts b/apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts new file mode 100644 index 000000000..2536233df --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts @@ -0,0 +1,49 @@ +const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active"; +const ONBOARDING_STATE_KEY = "dokploy_onboarding_state"; + +interface OnboardingState { + stepId?: string; + projectId?: string; + environmentId?: string; +} + +export const isOnboardingActive = () => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true"; + } catch { + return false; + } +}; + +export const markOnboardingActive = () => { + try { + window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true"); + } catch {} +}; + +export const clearOnboardingActive = () => { + try { + window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY); + window.localStorage.removeItem(ONBOARDING_STATE_KEY); + } catch {} +}; + +export const getOnboardingState = (): OnboardingState => { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +}; + +export const setOnboardingState = (state: OnboardingState) => { + try { + window.localStorage.setItem( + ONBOARDING_STATE_KEY, + JSON.stringify({ ...getOnboardingState(), ...state }), + ); + } catch {} +}; diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx new file mode 100644 index 000000000..da29cae7c --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx @@ -0,0 +1,295 @@ +import { defineStepper } from "@stepperize/react"; +import { CheckIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { GithubIcon } from "@/components/icons/data-tools-icons"; +import { Logo } from "@/components/shared/logo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; +import { getOnboardingState, setOnboardingState } from "./onboarding-lock"; +import { CompleteStep } from "./steps/complete-step"; +import { DeployStep } from "./steps/deploy-step"; +import { PlanStep } from "./steps/plan-step"; +import { ProjectStep } from "./steps/project-step"; +import { ServerStep } from "./steps/server-step"; +import { WelcomeStep } from "./steps/welcome-step"; + +const { useStepper, steps, Scoped } = defineStepper( + { id: "welcome", title: "Welcome" }, + { id: "plan", title: "Pick a plan" }, + { id: "project", title: "New project" }, + { id: "server", title: "Connect server" }, + { id: "deploy", title: "Ship something" }, + { id: "complete", title: "You're live" }, +); + +type StepId = (typeof steps)[number]["id"]; +const isStepId = (id: string | undefined): id is StepId => + !!id && steps.some((step) => step.id === id); + +interface Props { + onClose: () => void | Promise; +} + +export const OnboardingWizard = ({ onClose }: Props) => { + const router = useRouter(); + const persisted = getOnboardingState(); + const stepper = useStepper( + isStepId(persisted.stepId) ? persisted.stepId : undefined, + ); + const [projectId, setProjectId] = useState( + persisted.projectId, + ); + const [environmentId, setEnvironmentId] = useState( + persisted.environmentId, + ); + + const { data: isCloud = true } = api.settings.isCloud.useQuery(); + const visibleStepIds = isCloud + ? steps.map((step) => step.id) + : steps + .filter((step) => step.id !== "plan" && step.id !== "server") + .map((step) => step.id); + const visibleIndex = visibleStepIds.indexOf(stepper.current.id); + const isLastVisible = visibleIndex === visibleStepIds.length - 1; + const goToNextVisible = () => { + const nextId = visibleStepIds[visibleIndex + 1]; + if (nextId) stepper.goTo(nextId); + }; + + const [skipAllOpen, setSkipAllOpen] = useState(false); + const handleSkipAll = async () => { + await onClose(); + router.push( + projectId && environmentId + ? `/dashboard/project/${projectId}/environment/${environmentId}` + : "/dashboard/projects", + ); + }; + + useEffect(() => { + setOnboardingState({ stepId: stepper.current.id }); + }, [stepper.current.id]); + + const { error: projectCheckError } = api.project.one.useQuery( + { projectId: projectId ?? "" }, + { enabled: !!projectId, retry: false }, + ); + useEffect(() => { + if (!projectCheckError || !projectId) return; + setProjectId(undefined); + setEnvironmentId(undefined); + setOnboardingState({ projectId: undefined, environmentId: undefined }); + stepper.goTo("project"); + }, [projectCheckError, projectId]); + + return ( + <> +
+ + +
+
+ {stepper.switch({ + welcome: () => , + plan: () => , + project: () => ( + { + setProjectId(project.projectId); + setEnvironmentId(project.environmentId); + setOnboardingState({ + projectId: project.projectId, + environmentId: project.environmentId, + }); + goToNextVisible(); + }} + /> + ), + server: () => , + deploy: () => ( + + ), + complete: () => ( + + ), + })} +
+
+
+ + + + + Skip onboarding? + + If this is your first time using Dokploy, we recommend going + through these steps — it only takes a couple of minutes and gives + you a feel for how projects, servers and deployments fit together. + + + + + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx new file mode 100644 index 000000000..7378ab57d --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx @@ -0,0 +1,133 @@ +import { + BookIcon, + DatabaseIcon, + GitMergeIcon, + GlobeIcon, + UsersIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import ConfettiExplosion from "react-confetti-explosion"; +import { Button } from "@/components/ui/button"; +import { displayFont } from "../font"; + +interface Props { + projectId?: string; + environmentId?: string; + onFinish: () => void | Promise; +} + +const features = [ + { + icon: DatabaseIcon, + title: "Databases", + description: "Postgres, MySQL, MongoDB, Redis and more, one click away.", + }, + { + icon: GlobeIcon, + title: "Custom domains", + description: "Attach your own domains and get automatic HTTPS.", + }, + { + icon: GitMergeIcon, + title: "CI/CD", + description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.", + }, + { + icon: UsersIcon, + title: "Team collaboration", + description: "Invite teammates with fine-grained permissions.", + }, +]; + +export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => { + const router = useRouter(); + const [showConfetti, setShowConfetti] = useState(false); + const [isFinishing, setIsFinishing] = useState(false); + + useEffect(() => { + setShowConfetti(true); + }, []); + + const projectHref = + projectId && environmentId + ? `/dashboard/project/${projectId}/environment/${environmentId}` + : "/dashboard/projects"; + + const handleFinish = async () => { + setIsFinishing(true); + await onFinish(); + router.push(projectHref); + }; + + return ( +
+
+ {showConfetti && ( + + )} +
+ +
+ + Done + +

+ You're all set. +

+

+ This is just the beginning — here's some of what else you can do. +

+
+ +
+ {features.map((feature, index) => ( +
+
+ {String(index + 1).padStart(2, "0")} +
+
+ + + {feature.title} + + + {feature.description} + +
+
+ ))} +
+ +
+ + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx new file mode 100644 index 000000000..141aef2d3 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx @@ -0,0 +1,295 @@ +import copy from "copy-to-clipboard"; +import { + ArrowUpRightIcon, + CheckCircle2, + CopyIcon, + Loader2, + XCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +interface Props { + environmentId?: string; + onNext: () => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +const CURATED_TEMPLATES = [ + { id: "wordpress", name: "WordPress", logo: "wordpress.png" }, + { id: "ghost", name: "Ghost", logo: "ghost.jpeg" }, + { id: "n8n", name: "n8n", logo: "n8n.png" }, + { id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" }, +]; + +type Deploying = { kind: "app" | "template"; id: string; url: string }; + +export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { data: isCloud = true } = api.settings.isCloud.useQuery(); + const { data: servers } = api.server.withSSHKey.useQuery(); + const serverId = servers?.[0]?.serverId; + + const [deploying, setDeploying] = useState(null); + const [starting, setStarting] = useState(null); + + const { mutateAsync: deployNginx } = + api.application.deployNginxQuickstart.useMutation(); + const { mutateAsync: deployTemplate } = + api.compose.deployTemplate.useMutation(); + const { mutateAsync: deployCompose } = api.compose.deploy.useMutation(); + const utils = api.useUtils(); + + const { data: application } = api.application.one.useQuery( + { applicationId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "app", + refetchInterval: (query) => { + const status = query.state.data?.applicationStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + const { data: compose } = api.compose.one.useQuery( + { composeId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "template", + refetchInterval: (query) => { + const status = query.state.data?.composeStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + + const status = + deploying?.kind === "app" + ? application?.applicationStatus + : compose?.composeStatus; + + if (!environmentId || (isCloud && !serverId)) { + return ( +
+
+ + First deploy + +

Deploy something.

+
+ + {isCloud + ? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard." + : "You'll need a project before deploying — you can do that anytime from the dashboard."} + + +
+ ); + } + + const handleNginx = async () => { + setStarting("nginx"); + try { + const res = await deployNginx({ environmentId, serverId }); + setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the app", + ); + } finally { + setStarting(null); + } + }; + + const handleTemplate = async (id: string) => { + setStarting(id); + try { + const composeResult = await deployTemplate({ + environmentId, + serverId, + id, + }); + await deployCompose({ composeId: composeResult.composeId }); + const domains = await utils.domain.byComposeId.fetch({ + composeId: composeResult.composeId, + }); + const host = domains?.[0]?.host; + setDeploying({ + kind: "template", + id: composeResult.composeId, + url: host ? `http://${host}` : "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the template", + ); + } finally { + setStarting(null); + } + }; + + if (deploying) { + const isDone = status === "done"; + const isError = status === "error"; + + return ( +
+
+ + {isDone ? ( + + ) : isError ? ( + + ) : ( + + )} + {isDone ? "Live" : isError ? "Deploy failed" : "Deploying"} + +

+ {isDone + ? "It's live." + : isError + ? "Something went wrong." + : "Building your app..."} +

+

+ {isDone + ? "Your app is up and reachable at:" + : isError + ? "The deployment failed — you can check the logs from the dashboard later." + : "This usually takes a minute or two the first time."} +

+
+ + {isDone && deploying.url ? ( +
+
+ + + Reachable now + +
+
+ + {deploying.url} + +
+ + +
+
+
+ ) : !isDone && !isError ? ( +
+
+ + Waiting for the build to finish... +
+
+ ) : null} + + +
+ ); + } + + return ( +
+
+ + First deploy + +

Ship something.

+

+ Pick a quickstart — we'll generate a domain and deploy it for you. +

+
+ +
+ + +
+
+

Docker Compose template

+

+ Popular open source stacks, one click away. +

+
+
+ {CURATED_TEMPLATES.map((template) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx new file mode 100644 index 000000000..1f563854f --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -0,0 +1,207 @@ +import { loadStripe } from "@stripe/stripe-js"; +import { ArrowRightIcon, CheckIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + calculatePriceHobby, + calculatePriceStartup, + STARTUP_SERVERS_INCLUDED, +} from "@/components/dashboard/settings/billing/show-billing"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, +); + +interface Props { + onNext: () => void; +} + +export const PlanStep = ({ onNext }: Props) => { + const [loadingTier, setLoadingTier] = useState< + "hobby" | "startup" | "trial" | null + >(null); + const { data } = api.stripe.getProducts.useQuery(); + const { mutateAsync: createCheckoutSession } = + api.stripe.createCheckoutSession.useMutation(); + const { mutateAsync: startFreeTrial } = api.stripe.startFreeTrial.useMutation(); + const utils = api.useUtils(); + + const handleCheckout = async (tier: "hobby" | "startup") => { + if (!data) return; + const productId = + tier === "hobby" ? data.hobbyProductId : data.startupProductId; + if (!productId) return; + setLoadingTier(tier); + try { + const stripe = await stripePromise; + const session = await createCheckoutSession({ + tier, + productId, + serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, + isAnnual: false, + }); + await stripe?.redirectToCheckout({ sessionId: session.sessionId }); + } catch { + toast.error("Error starting checkout"); + setLoadingTier(null); + } + }; + + const handleTrial = async () => { + setLoadingTier("trial"); + try { + await startFreeTrial(); + await utils.project.onboardingStatus.invalidate(); + toast.success("Your 14-day trial has started"); + onNext(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error starting trial", + ); + } finally { + setLoadingTier(null); + } + }; + + return ( +
+
+ + Billing + +

+ Start free, upgrade when ready. +

+

+ No credit card for the trial — add one only if you decide to stay. +

+
+ +
+
+
+ + Recommended + +

+ 14-day free trial +

+

+ No card required — cancel anytime. +

+
    + {["1 server included", "Unlimited apps & databases", "Community support"].map( + (f) => ( +
  • + + {f} +
  • + ), + )} +
+
+ +
+
+ +
+
+
+

Hobby

+

+ For individual developers +

+

+ ${calculatePriceHobby(1, false).toFixed(2)} + + {" "} + /mo + +

+
    + {[ + "1 server included", + "Unlimited apps & databases", + "2 environments", + "Community support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+ +
+
+

Startup

+

+ For small to mid-size teams +

+

+ ${calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(2)} + + {" "} + /mo + +

+
    + {[ + `${STARTUP_SERVERS_INCLUDED} servers included`, + "Unlimited users & environments", + "Basic RBAC + 2FA", + "Email & chat support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx new file mode 100644 index 000000000..24c37d9d9 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx @@ -0,0 +1,118 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), +}); +type Schema = z.infer; + +interface Props { + onNext: (project: { projectId: string; environmentId: string }) => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +export const ProjectStep = ({ onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { mutateAsync, isPending } = api.project.create.useMutation(); + const utils = api.useUtils(); + + const form = useForm({ + defaultValues: { name: "My First Project", description: "" }, + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: Schema) => { + try { + const result = await mutateAsync(data); + await utils.project.all.invalidate(); + toast.success("Project created"); + onNext({ + projectId: result.project.projectId, + environmentId: result.environment?.environmentId ?? "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error creating the project", + ); + } + }; + + return ( +
+
+ + Workspace + +

Create your first project.

+

+ Projects group your apps, databases and environments together. +

+
+ +
+ + ( + + Name + + + + + + )} + /> + ( + + Description (optional) + +