Merge pull request #1911 from Dokploy/canary
Some checks are pending
Build Docker images / build-and-push-cloud-image (push) Waiting to run
Build Docker images / build-and-push-schedule-image (push) Waiting to run
Build Docker images / build-and-push-server-image (push) Waiting to run
Dokploy Docker Build / docker-amd (push) Waiting to run
Dokploy Docker Build / docker-arm (push) Waiting to run
Dokploy Docker Build / combine-manifests (push) Blocked by required conditions
Dokploy Docker Build / generate-release (push) Blocked by required conditions
Dokploy Monitoring Build / docker-amd (push) Waiting to run
Dokploy Monitoring Build / docker-arm (push) Waiting to run
Dokploy Monitoring Build / combine-manifests (push) Blocked by required conditions

🚀 Release v0.22.5
This commit is contained in:
Mauricio Siu
2025-05-17 16:19:10 -06:00
committed by GitHub
29 changed files with 384 additions and 187 deletions

View File

@@ -49,7 +49,7 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.35.0
ARG NIXPACKS_VERSION=1.39.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \
@@ -63,4 +63,4 @@ RUN curl -sSL https://railpack.com/install.sh | bash
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
CMD [ "pnpm", "start" ]
CMD [ "pnpm", "start" ]

View File

@@ -8,7 +8,7 @@ COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y python3 make g++ git git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

View File

@@ -263,7 +263,7 @@ export const ShowImport = ({ composeId }: Props) => {
{templateInfo.template.envs.map((env, index) => (
<div
key={index}
className="rounded-lg border bg-card p-2 font-mono text-sm"
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
>
{env}
</div>
@@ -328,7 +328,7 @@ export const ShowImport = ({ composeId }: Props) => {
<DialogDescription>Mount File Content</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[25vh] pr-4">
<ScrollArea className="h-[45vh] pr-4">
<CodeEditor
language="yaml"
value={selectedMount?.content || ""}

View File

@@ -86,7 +86,7 @@ export const ShowDeployments = ({
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy/${refreshToken}`}
{`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`}
</span>
{(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} />

View File

@@ -186,30 +186,19 @@ export const ShowDomains = ({ id, type }: Props) => {
return (
<Card
key={item.domainId}
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent h-fit"
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
{/* Service & Domain Info */}
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
{item.serviceName && (
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</Badge>
)}
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4" />
</Link>
</div>
<div className="flex gap-2">
<div className="flex items-center justify-between flex-wrap gap-y-2">
{item.serviceName && (
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</Badge>
)}
<div className="flex gap-2 flex-wrap">
{!item.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
@@ -266,6 +255,16 @@ export const ShowDomains = ({ id, type }: Props) => {
</DialogAction>
</div>
</div>
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
</div>
{/* Domain Details */}
<div className="flex flex-wrap gap-3">

View File

@@ -136,7 +136,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules || false,
});
}
}, [form.reset, data, form]);
}, [form.reset, data?.applicationId, form]);
const onSubmit = async (data: BitbucketProvider) => {
await mutateAsync({
@@ -435,7 +435,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -454,7 +454,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -53,7 +53,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
registryURL: data.registryUrl || "",
});
}
}, [form.reset, data, form]);
}, [form.reset, data?.applicationId, form]);
const onSubmit = async (values: DockerProvider) => {
await mutateAsync({

View File

@@ -262,7 +262,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -281,7 +281,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -158,7 +158,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules || false,
});
}
}, [form.reset, data, form]);
}, [form.reset, data?.applicationId, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
@@ -470,7 +470,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();

View File

@@ -134,7 +134,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
}, [form.reset, data?.applicationId, form]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -474,7 +474,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();

View File

@@ -141,7 +141,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
}, [form.reset, data?.applicationId, form]);
const onSubmit = async (data: GitlabProvider) => {
await mutateAsync({
@@ -452,7 +452,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();

View File

@@ -136,7 +136,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
}, [form.reset, data?.composeId, form]);
const onSubmit = async (data: BitbucketProvider) => {
await mutateAsync({
@@ -437,7 +437,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -456,7 +456,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -263,7 +263,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -282,7 +282,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -142,7 +142,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
}, [form.reset, data?.composeId, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
@@ -437,7 +437,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();

View File

@@ -134,7 +134,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
}, [form.reset, data?.composeId, form]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -474,7 +474,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -496,7 +496,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -142,7 +142,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
}, [form.reset, data?.composeId, form]);
const onSubmit = async (data: GitlabProvider) => {
await mutateAsync({
@@ -453,7 +453,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -472,7 +472,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -10,7 +10,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { Puzzle, RefreshCw } from "lucide-react";
import { Loader2, Puzzle, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -66,36 +66,50 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
{isLoading ? (
<div className="flex flex-row items-center justify-center min-h-[25rem] border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
</div>
) : compose?.length === 5 ? (
<div className="border p-4 rounded-md flex flex-col items-center justify-center min-h-[25rem]">
<Puzzle className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-muted-foreground">
No converted compose data available.
</span>
</div>
) : (
<>
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"
isLoading={isLoading}
onClick={() => {
mutateAsync({ composeId })
.then(() => {
refetch();
toast.success("Fetched source type");
})
.catch((err) => {
toast.error("Error fetching source type", {
description: err.message,
});
});
}}
>
Refresh <RefreshCw className="ml-2 h-4 w-4" />
</Button>
</div>
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"
isLoading={isLoading}
onClick={() => {
mutateAsync({ composeId })
.then(() => {
refetch();
toast.success("Fetched source type");
})
.catch((err) => {
toast.error("Error fetching source type", {
description: err.message,
});
});
}}
>
Refresh <RefreshCw className="ml-2 h-4 w-4" />
</Button>
</div>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
</>
)}
</DialogContent>
</Dialog>
);

View File

@@ -15,6 +15,7 @@ import { Copy, Loader2 } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
export type Services = {
appName: string;
@@ -48,6 +49,7 @@ export const DuplicateProject = ({
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
const utils = api.useUtils();
const router = useRouter();
@@ -59,9 +61,15 @@ export const DuplicateProject = ({
api.project.duplicate.useMutation({
onSuccess: async (newProject) => {
await utils.project.all.invalidate();
toast.success("Project duplicated successfully");
toast.success(
duplicateType === "new-project"
? "Project duplicated successfully"
: "Services duplicated successfully",
);
setOpen(false);
router.push(`/dashboard/project/${newProject.projectId}`);
if (duplicateType === "new-project") {
router.push(`/dashboard/project/${newProject.projectId}`);
}
},
onError: (error) => {
toast.error(error.message);
@@ -69,7 +77,7 @@ export const DuplicateProject = ({
});
const handleDuplicate = async () => {
if (!name) {
if (duplicateType === "new-project" && !name) {
toast.error("Project name is required");
return;
}
@@ -83,6 +91,7 @@ export const DuplicateProject = ({
id: service.id,
type: service.type,
})),
duplicateInSameProject: duplicateType === "same-project",
});
};
@@ -95,6 +104,7 @@ export const DuplicateProject = ({
// Reset form when closing
setName("");
setDescription("");
setDuplicateType("new-project");
}
}}
>
@@ -106,32 +116,54 @@ export const DuplicateProject = ({
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Project</DialogTitle>
<DialogTitle>Duplicate Services</DialogTitle>
<DialogDescription>
Create a new project with the selected services
Choose where to duplicate the selected services
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New project name"
/>
<Label>Duplicate to</Label>
<RadioGroup
value={duplicateType}
onValueChange={setDuplicateType}
className="grid gap-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="new-project" id="new-project" />
<Label htmlFor="new-project">New project</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="same-project" id="same-project" />
<Label htmlFor="same-project">Same project</Label>
</div>
</RadioGroup>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Project description (optional)"
/>
</div>
{duplicateType === "new-project" && (
<>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New project name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Project description (optional)"
/>
</div>
</>
)}
<div className="grid gap-2">
<Label>Selected services to duplicate</Label>
@@ -159,10 +191,14 @@ export const DuplicateProject = ({
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Duplicating...
{duplicateType === "new-project"
? "Duplicating project..."
: "Duplicating services..."}
</>
) : duplicateType === "new-project" ? (
"Duplicate project"
) : (
"Duplicate"
"Duplicate services"
)}
</Button>
</DialogFooter>

View File

@@ -156,6 +156,67 @@ export const HandleServers = ({ serverId }: Props) => {
remotely.
</DialogDescription>
</DialogHeader>
<div>
<p className="text-primary text-sm font-medium">
You will need to purchase or rent a Virtual Private Server (VPS) to
proceed, we recommend to use one of these providers since has been
heavily tested.
</p>
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
<li>
<a
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
className="text-link underline"
>
Hostinger - Get 20% Discount
</a>
</li>
<li>
<a
href=" https://app.americancloud.com/register?ref=dokploy"
className="text-link underline"
>
American Cloud - Get $20 Credits
</a>
</li>
<li>
<a
href="https://m.do.co/c/db24efd43f35"
className="text-link underline"
>
DigitalOcean - Get $200 Credits
</a>
</li>
<li>
<a
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
className="text-link underline"
>
Hetzner - Get 20 Credits
</a>
</li>
<li>
<a
href="https://www.vultr.com/?ref=9679828"
className="text-link underline"
>
Vultr
</a>
</li>
<li>
<a
href="https://www.linode.com/es/pricing/#compute-shared"
className="text-link underline"
>
Linode
</a>
</li>
</ul>
<AlertBlock className="mt-4 px-4">
You are free to use whatever provider, but we recommend to use one
of the above, to avoid issues.
</AlertBlock>
</div>
{!canCreateMoreServers && (
<AlertBlock type="warning">
You cannot create more servers,{" "}

View File

@@ -177,6 +177,14 @@ export const WelcomeSuscription = () => {
Hostinger - Get 20% Discount
</a>
</li>
<li>
<a
href=" https://app.americancloud.com/register?ref=dokploy"
className="text-link underline"
>
American Cloud - Get $20 Credits
</a>
</li>
<li>
<a
href="https://m.do.co/c/db24efd43f35"

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.22.4",
"version": "v0.22.5",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -80,7 +80,13 @@ export default function Custom404({ statusCode, error }: Props) {
<footer className="mt-auto text-center py-5">
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-sm text-gray-500">
Submit Log in issue on Github
<Link
href="https://github.com/Dokploy/dokploy/issues"
target="_blank"
className="underline hover:text-primary transition-colors"
>
Submit Log in issue on Github
</Link>
</p>
</div>
</footer>

View File

@@ -217,12 +217,12 @@ const Service = (
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList
className={cn(
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
"xl:grid xl:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId
? "lg:grid-cols-9"
? "xl:grid-cols-9"
: data?.serverId
? "lg:grid-cols-8"
: "lg:grid-cols-9",
? "xl:grid-cols-8"
: "xl:grid-cols-9",
)}
>
<TabsTrigger value="general">General</TabsTrigger>

View File

@@ -439,7 +439,15 @@ export const composeRouter = createTRPCRouter({
}
const projectName = slugify(`${project.name} ${input.id}`);
const generate = processTemplate(template.config, {
const appName = `${projectName}-${generatePassword(6)}`;
const config = {
...template.config,
variables: {
APP_NAME: appName,
...template.config.variables,
},
};
const generate = processTemplate(config, {
serverIp: serverIp,
projectName: projectName,
});
@@ -451,7 +459,7 @@ export const composeRouter = createTRPCRouter({
serverId: input.serverId,
name: input.id,
sourceType: "raw",
appName: `${projectName}-${generatePassword(6)}`,
appName: appName,
isolatedDeployment: true,
});
@@ -605,7 +613,15 @@ export const composeRouter = createTRPCRouter({
});
}
const processedTemplate = processTemplate(config, {
const configModified = {
...config,
variables: {
APP_NAME: compose.appName,
...config.variables,
},
};
const processedTemplate = processTemplate(configModified, {
serverIp: serverIp,
projectName: compose.appName,
});
@@ -675,7 +691,15 @@ export const composeRouter = createTRPCRouter({
});
}
const processedTemplate = processTemplate(config, {
const configModified = {
...config,
variables: {
APP_NAME: compose.appName,
...config.variables,
},
};
const processedTemplate = processTemplate(configModified, {
serverIp: serverIp,
projectName: compose.appName,
});

View File

@@ -309,6 +309,7 @@ export const projectRouter = createTRPCRouter({
}),
)
.optional(),
duplicateInSameProject: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -331,15 +332,17 @@ export const projectRouter = createTRPCRouter({
});
}
// Create new project
const newProject = await createProject(
{
name: input.name,
description: input.description,
env: sourceProject.env,
},
ctx.session.activeOrganizationId,
);
// Create new project or use existing one
const targetProject = input.duplicateInSameProject
? sourceProject
: await createProject(
{
name: input.name,
description: input.description,
env: sourceProject.env,
},
ctx.session.activeOrganizationId,
);
if (input.includeServices) {
const servicesToDuplicate = input.selectedServices || [];
@@ -362,7 +365,10 @@ export const projectRouter = createTRPCRouter({
const newApplication = await createApplication({
...application,
projectId: newProject.projectId,
name: input.duplicateInSameProject
? `${application.name} (copy)`
: application.name,
projectId: targetProject.projectId,
});
for (const domain of domains) {
@@ -423,7 +429,10 @@ export const projectRouter = createTRPCRouter({
const newPostgres = await createPostgres({
...postgres,
projectId: newProject.projectId,
name: input.duplicateInSameProject
? `${postgres.name} (copy)`
: postgres.name,
projectId: targetProject.projectId,
});
for (const mount of mounts) {
@@ -449,7 +458,10 @@ export const projectRouter = createTRPCRouter({
await findMariadbById(id);
const newMariadb = await createMariadb({
...mariadb,
projectId: newProject.projectId,
name: input.duplicateInSameProject
? `${mariadb.name} (copy)`
: mariadb.name,
projectId: targetProject.projectId,
});
for (const mount of mounts) {
@@ -475,7 +487,10 @@ export const projectRouter = createTRPCRouter({
await findMongoById(id);
const newMongo = await createMongo({
...mongo,
projectId: newProject.projectId,
name: input.duplicateInSameProject
? `${mongo.name} (copy)`
: mongo.name,
projectId: targetProject.projectId,
});
for (const mount of mounts) {
@@ -501,7 +516,10 @@ export const projectRouter = createTRPCRouter({
await findMySqlById(id);
const newMysql = await createMysql({
...mysql,
projectId: newProject.projectId,
name: input.duplicateInSameProject
? `${mysql.name} (copy)`
: mysql.name,
projectId: targetProject.projectId,
});
for (const mount of mounts) {
@@ -526,7 +544,10 @@ export const projectRouter = createTRPCRouter({
const { redisId, mounts, ...redis } = await findRedisById(id);
const newRedis = await createRedis({
...redis,
projectId: newProject.projectId,
name: input.duplicateInSameProject
? `${redis.name} (copy)`
: redis.name,
projectId: targetProject.projectId,
});
for (const mount of mounts) {
@@ -545,7 +566,10 @@ export const projectRouter = createTRPCRouter({
await findComposeById(id);
const newCompose = await createCompose({
...compose,
projectId: newProject.projectId,
name: input.duplicateInSameProject
? `${compose.name} (copy)`
: compose.name,
projectId: targetProject.projectId,
});
for (const mount of mounts) {
@@ -572,21 +596,20 @@ export const projectRouter = createTRPCRouter({
};
// Duplicate selected services
for (const service of servicesToDuplicate) {
await duplicateService(service.id, service.type);
}
}
if (ctx.user.role === "member") {
if (!input.duplicateInSameProject && ctx.user.role === "member") {
await addNewProject(
ctx.user.id,
newProject.projectId,
targetProject.projectId,
ctx.session.activeOrganizationId,
);
}
return newProject;
return targetProject;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",

View File

@@ -356,20 +356,20 @@ const installUtilities = () => `
case "$OS_TYPE" in
arch)
pacman -Sy --noconfirm --needed curl wget git jq openssl >/dev/null || true
pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true
;;
alpine)
sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
apk update >/dev/null
apk add curl wget git jq openssl sudo unzip tar >/dev/null
apk add curl wget git git-lfs jq openssl sudo unzip tar >/dev/null
;;
ubuntu | debian | raspbian)
DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y unzip curl wget git jq openssl >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y unzip curl wget git git-lfs jq openssl >/dev/null
;;
centos | fedora | rhel | ol | rocky | almalinux | amzn)
if [ "$OS_TYPE" = "amzn" ]; then
dnf install -y wget git jq openssl >/dev/null
dnf install -y wget git git-lfs jq openssl >/dev/null
else
if ! command -v dnf >/dev/null; then
yum install -y dnf >/dev/null
@@ -377,12 +377,12 @@ const installUtilities = () => `
if ! command -v curl >/dev/null; then
dnf install -y curl >/dev/null
fi
dnf install -y wget git jq openssl unzip >/dev/null
dnf install -y wget git git-lfs jq openssl unzip >/dev/null
fi
;;
sles | opensuse-leap | opensuse-tumbleweed)
zypper refresh >/dev/null
zypper install -y curl wget git jq openssl >/dev/null
zypper install -y curl wget git git-lfs jq openssl >/dev/null
;;
*)
echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now."
@@ -577,7 +577,7 @@ const installNixpacks = () => `
if command_exists nixpacks; then
echo "Nixpacks already installed ✅"
else
export NIXPACKS_VERSION=1.35.0
export NIXPACKS_VERSION=1.39.0
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
fi

View File

@@ -3,7 +3,7 @@ import { execAsync } from "../process/execAsync";
import { getS3Credentials, normalizeS3Path } from "./utils";
import { findDestinationById } from "@dokploy/server/services/destination";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import { mkdtemp } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
@@ -51,10 +51,20 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
const postgresContainerId = containerId.trim();
const postgresCommand = `docker exec ${postgresContainerId} pg_dump -v -Fc -U dokploy -d dokploy > '${tempDir}/database.sql'`;
// First dump the database inside the container
const dumpCommand = `docker exec ${postgresContainerId} pg_dump -v -Fc -U dokploy -d dokploy -f /tmp/database.sql`;
writeStream.write(`Running dump command: ${dumpCommand}\n`);
await execAsync(dumpCommand);
writeStream.write(`Running command: ${postgresCommand}\n`);
await execAsync(postgresCommand);
// Then copy the file from the container to host
const copyCommand = `docker cp ${postgresContainerId}:/tmp/database.sql ${tempDir}/database.sql`;
writeStream.write(`Copying database dump: ${copyCommand}\n`);
await execAsync(copyCommand);
// Clean up the temp file in the container
const cleanupCommand = `docker exec ${postgresContainerId} rm -f /tmp/database.sql`;
writeStream.write(`Cleaning up temp file: ${cleanupCommand}\n`);
await execAsync(cleanupCommand);
await execAsync(
`rsync -av --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`,
@@ -77,7 +87,11 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
await updateDeploymentStatus(deployment.deploymentId, "done");
return true;
} finally {
await execAsync(`rm -rf ${tempDir}`);
try {
await rm(tempDir, { recursive: true, force: true });
} catch (cleanupError) {
console.error("Cleanup error:", cleanupError);
}
}
} catch (error) {
console.error("Backup error:", error);

View File

@@ -190,7 +190,8 @@ const createEnvFile = (compose: ComposeNested) => {
join(COMPOSE_PATH, appName, "code", "docker-compose.yml");
const envFilePath = join(dirname(composeFilePath), ".env");
let envContent = env || "";
let envContent = `APP_NAME=${appName}\n`;
envContent += env || "";
if (!envContent.includes("DOCKER_CONFIG")) {
envContent += "\nDOCKER_CONFIG=/root/.docker/config.json";
}
@@ -219,7 +220,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
const envFilePath = join(dirname(composeFilePath), ".env");
let envContent = env || "";
let envContent = `APP_NAME=${appName}\n`;
envContent += env || "";
if (!envContent.includes("DOCKER_CONFIG")) {
envContent += "\nDOCKER_CONFIG=/root/.docker/config.json";
}

View File

@@ -246,32 +246,16 @@ export const getGitlabRepositories = async (gitlabId?: string) => {
const gitlabProvider = await findGitlabById(gitlabId);
const response = await fetch(
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&owned=true&page=${0}&per_page=${100}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,
},
},
);
const allProjects = await validateGitlabProvider(gitlabProvider);
if (!response.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to fetch repositories: ${response.statusText}`,
});
}
const repositories = await response.json();
const filteredRepos = repositories.filter((repo: any) => {
const filteredRepos = allProjects.filter((repo: any) => {
const { full_path, kind } = repo.namespace;
const groupName = gitlabProvider.groupName?.toLowerCase();
if (groupName) {
const isIncluded = groupName
.split(",")
.some((name) => full_path.toLowerCase().includes(name));
.some((name) => full_path === name);
return isIncluded && kind === "group";
}
@@ -432,34 +416,60 @@ export const testGitlabConnection = async (
const gitlabProvider = await findGitlabById(gitlabId);
const response = await fetch(
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&owned=true&page=${0}&per_page=${100}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,
},
},
);
if (!response.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to fetch repositories: ${response.statusText}`,
});
}
const repositories = await response.json();
const repositories = await validateGitlabProvider(gitlabProvider);
const filteredRepos = repositories.filter((repo: any) => {
const { full_path, kind } = repo.namespace;
if (groupName) {
return groupName
.split(",")
.some((name) => full_path.toLowerCase().includes(name));
return groupName.split(",").some((name) => full_path === name);
}
return kind === "user";
});
return filteredRepos.length;
};
export const validateGitlabProvider = async (gitlabProvider: Gitlab) => {
try {
const allProjects = [];
let page = 1;
const perPage = 100; // GitLab's max per page is 100
while (true) {
const response = await fetch(
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&owned=true&page=${page}&per_page=${perPage}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,
},
},
);
if (!response.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to fetch repositories: ${response.statusText}`,
});
}
const projects = await response.json();
if (projects.length === 0) {
break;
}
allProjects.push(...projects);
page++;
const total = response.headers.get("x-total");
if (total && allProjects.length >= Number.parseInt(total)) {
break;
}
}
return allProjects;
} catch (error) {
throw error;
}
};