Compare commits

..

54 Commits

Author SHA1 Message Date
Mauricio Siu
d2eaa4b40b chore: bump version to v0.21.6 in package.json 2025-04-12 22:12:24 -06:00
Mauricio Siu
7d7f2b4b1f Merge pull request #1692 from ron-tayler/canary
Fixed network search in Traefik Labels for the service
2025-04-12 22:10:20 -06:00
Mauricio Siu
74ec8f4594 Merge pull request #1651 from Axodouble/Dutch-NL
feat: Add Dutch / NL language translations
2025-04-12 21:22:17 -06:00
Mauricio Siu
76c0bff13a Merge pull request #1694 from Dokploy/1593-add-way-to-customize-issuer-name-in-totp-app
chore: update dependencies and enhance 2FA form
2025-04-12 21:11:43 -06:00
Mauricio Siu
9b5cd0f5fe chore: update dependencies and enhance 2FA form
- Updated `better-auth` to version 1.2.6 in multiple package.json files.
- Updated `@better-auth/utils` to version 0.2.4 in server package.json.
- Added optional `issuer` field to the 2FA form for enhanced user experience.
- Removed unnecessary console log from the profile form component.
2025-04-12 21:11:21 -06:00
Ron_Tayler
efee798880 Fixed network search in Traefik Labels for the service 2025-04-12 22:02:27 +03:00
Mauricio Siu
1c470b8ba7 Merge pull request #1671 from vytenisstaugaitis/canary
fix: correct message on preview deployments disabling
2025-04-12 02:39:47 -06:00
Mauricio Siu
692864ced1 Merge pull request #1687 from Dokploy/git-fetch-origin-git-checkout-1659-environment-variable-parsing-issue-with-character-in-railpack-nuxt-app-or-other
fix(railpack): update environment variable handling to include quotes…
2025-04-12 02:30:47 -06:00
Mauricio Siu
9ca61476d2 Merge pull request #1688 from Dokploy/1675-password-input-on-profile-page-gets-cleared-unepxectedly
fix(profile-form): disable refetch on window focus for user query
2025-04-12 02:28:04 -06:00
Mauricio Siu
773a610be1 fix(profile-form): disable refetch on window focus for user query 2025-04-12 02:27:43 -06:00
Mauricio Siu
37f9e073f0 fix(railpack): update environment variable handling to include quotes for consistency 2025-04-12 02:16:39 -06:00
autofix-ci[bot]
d335a9515d [autofix.ci] apply automated fixes 2025-04-09 15:53:37 +00:00
vytenisstaugaitis
7a5a3de43d fix: correct message on preview deployments disabling 2025-04-09 18:47:34 +03:00
Mauricio Siu
ee6ad07c0a Update package.json 2025-04-08 22:44:17 -06:00
Mauricio Siu
48fe26b204 Merge pull request #1661 from henriklovhaug/canary
Fix(Traefik) Move `passHostHeader` to correct indentation
2025-04-08 22:43:52 -06:00
autofix-ci[bot]
3ede89fe8a [autofix.ci] apply automated fixes 2025-04-08 20:27:50 +00:00
Henrik Tøn Løvhaug
fa698d173e Move passHostHeader to correct position 2025-04-08 22:24:19 +02:00
Axodouble
05f43ad06b FEAT: Add Dutch / NL language translations 2025-04-07 10:36:37 +02:00
Mauricio Siu
8f0697b0e9 Update package.json 2025-04-06 15:29:13 -06:00
Mauricio Siu
61a20f13e2 Merge pull request #1629 from krokodaws/fix/bulk-actions
fix: resolve incorrect endpoints for database bulk actions (#1626)
2025-04-06 11:09:48 -06:00
Mauricio Siu
148b1ff2db refactor(user-nav): remove settings option for owner role and delete settings page
- Removed the "Settings" dropdown menu item for users with the "owner" role in the UserNav component.
- Deleted the settings page implementation, including all related components and logic.
2025-04-06 03:32:39 -06:00
Mauricio Siu
1beceb7ee7 Merge pull request #1641 from Dokploy/1590-backup-maxing-buffer-size
fix(backups): suppress output during backup and restore processes
2025-04-06 03:22:24 -06:00
Mauricio Siu
bea0316bbd fix(backups): suppress output during backup and restore processes
- Updated the backup command in runWebServerBackup to suppress output by redirecting it to /dev/null.
- Modified the restore command in restoreWebServerBackup to also suppress output during the extraction of backups.
2025-04-06 03:22:03 -06:00
Mauricio Siu
b2a8572d10 Merge pull request #1640 from Dokploy/1633-bug-using-reload-for-an-application-with-multiple-replicas
fix(application): enhance application reload process with error handl…
2025-04-06 03:06:48 -06:00
Mauricio Siu
2352939e87 fix(application): enhance application reload process with error handling and Docker container management
- Added mechanizeDockerContainer function to manage Docker container state during application reload.
- Improved error handling to update application status on failure and provide more informative error messages.
- Refactored authorization check to throw an error if the user is not authorized to reload the application.
2025-04-06 03:05:46 -06:00
Mauricio Siu
48ec0a74ad Merge pull request #1637 from Dokploy/1601-duplicate-domain-bug
feat(settings): add HTTPS support and update user schema
2025-04-06 02:42:04 -06:00
Mauricio Siu
bca6af77fd fix(traefik): update server configuration to use new host parameter and ensure HTTPS is set correctly
- Modified the updateServerTraefik function to utilize the newHost parameter instead of user.host.
- Ensured the HTTPS field is correctly initialized in the test for server configuration.
2025-04-06 02:37:15 -06:00
Mauricio Siu
b3bd9ba1ce Merge pull request #1616 from vicke4/webserver-backup-auto-delete
fix(backups): web-server backups auto-deletion
2025-04-06 02:31:09 -06:00
Mauricio Siu
5a9c763c4f Merge pull request #1631 from lorenzomigliorero/fix/remove-sensitive-files-from-static-build
fix(security): remove sensitive files on static build
2025-04-06 02:30:35 -06:00
Mauricio Siu
4b51744d0d Merge pull request #1638 from Dokploy/1588-invalid-behavior-when-using-s3-prefix-destination-for-backups
feat(backups): implement normalizeS3Path utility and integrate into b…
2025-04-06 02:15:37 -06:00
Mauricio Siu
e5a3e56e13 fix(tests): initialize HTTPS field in user schema for server config tests 2025-04-06 02:11:46 -06:00
Mauricio Siu
42fa4008ab feat(backups): implement normalizeS3Path utility and integrate into backup processes
- Added normalizeS3Path function to standardize S3 path formatting by trimming whitespace and removing leading/trailing slashes.
- Updated backup-related modules (MySQL, MongoDB, PostgreSQL, MariaDB, and web server) to utilize normalizeS3Path for consistent S3 path handling.
- Introduced unit tests for normalizeS3Path to ensure correct functionality across various input scenarios.
2025-04-06 02:09:23 -06:00
Mauricio Siu
1605aedd6e feat(settings): add HTTPS support and update user schema
- Introduced a new boolean field 'https' in the user schema to manage HTTPS settings.
- Updated the web domain form to include an HTTPS toggle, allowing users to enable or disable HTTPS.
- Enhanced validation logic to ensure certificate type is required when HTTPS is enabled.
- Modified Traefik configuration to handle HTTPS routing based on user settings.
2025-04-06 01:41:47 -06:00
Mauricio Siu
14bc26e065 feat(websocket): enhance WebSocket server with request validation and client instantiation
- Added request validation to ensure user authentication before establishing WebSocket connections.
- Refactored WebSocket client instantiation to simplify connection management.
2025-04-06 00:07:41 -06:00
Mauricio Siu
6c8eb3b711 Merge pull request #1635 from Dokploy/fix/use-exec-file
feat(registry): refactor Docker login command execution to use execFi…
2025-04-05 23:05:17 -06:00
Mauricio Siu
cb20950dd9 feat(registry): refactor Docker login command execution to use execFileAsync for improved input handling 2025-04-05 23:03:57 -06:00
Mauricio Siu
e83efa3379 Merge pull request #1630 from lorenzomigliorero/feat/improve-projects-show-grid
feat: improve projects grid
2025-04-04 23:45:09 -06:00
Lorenzo Migliorero
5863e45c13 remove sensitive files on static build 2025-04-04 20:18:56 +02:00
Lorenzo Migliorero
2c09b63bf9 feat: improve projects show grid 2025-04-04 19:19:09 +02:00
krokodaws
eff2657e70 fix: resolve incorrect endpoints for database bulk actions (#1626)
Update bulk action endpoints for database services:
- Use `/api/trpc/redis.start` and `/api/trpc/redis.stop` for Redis
- Use `/api/trpc/postgres.start` and `/api/trpc/postgres.stop` for PostgreSQL
- Retain `/api/trpc/compose.start` and `/api/trpc/compose.stop` for Docker Compose services

Tested with a project including Gitea, Redis, and PostgreSQL. Bulk start/stop operations now function correctly for all service types.

Closes #1626
2025-04-04 19:21:30 +03:00
Mauricio Siu
36172491a4 refactor(websocket): streamline WebSocket server setup and client instantiation
- Removed the request validation logic from the WebSocket connection handler.
- Added a cleanup function to close the WebSocket server.
- Introduced a singleton pattern for the WebSocket client to manage connections more efficiently.
2025-04-04 01:55:29 -06:00
Mauricio Siu
d43b098a7a Merge pull request #1623 from Dokploy/1606-project-name-starting-with-a-number-causes-a-conflict-with-application-name
feat(handle-project): enhance project name validation to disallow sta…
2025-04-04 01:28:07 -06:00
Mauricio Siu
8479f20205 feat(handle-project): enhance project name validation to disallow starting with a number 2025-04-04 01:27:53 -06:00
Ganapathy S
6cb4159d54 Merge branch 'Dokploy:canary' into webserver-backup-auto-delete 2025-04-03 15:31:48 +05:30
Mauricio Siu
031d0ce315 Update dokploy version to v0.21.3 in package.json 2025-04-03 00:22:57 -06:00
Mauricio Siu
131a1acbbe Merge pull request #1617 from Dokploy/fix/cover-edge-cases-processor-template
fix(templates): add optional chaining to prevent errors when accessin…
2025-04-03 00:22:44 -06:00
Mauricio Siu
9a839de022 feat(templates): add username and email generation using faker 2025-04-03 00:22:29 -06:00
Mauricio Siu
b9de05015f fix(templates): add optional chaining to prevent errors when accessing template properties 2025-04-03 00:17:09 -06:00
autofix-ci[bot]
e176def5b6 [autofix.ci] apply automated fixes 2025-04-03 06:12:08 +00:00
vicke4
94c947e288 fix(backups): web-server backups auto-deletion 2025-04-03 11:41:21 +05:30
Mauricio Siu
0bdaa81263 fix(backups): replace findAdmin with db query to fetch admin by role for cron job initialization 2025-04-02 07:10:19 -06:00
Mauricio Siu
baf36b6fb6 Merge pull request #1608 from Dokploy/fix/move-cron-to-after-migration
Fix/move cron to after migration
2025-04-02 06:58:38 -06:00
Mauricio Siu
d632e83799 Update dokploy version to v0.21.2 in package.json 2025-04-02 06:53:41 -06:00
Mauricio Siu
6f52edd845 fix(backups): ensure initCronJobs is awaited before migration to improve backup reliability 2025-04-02 06:53:32 -06:00
41 changed files with 5977 additions and 439 deletions

View File

@@ -14,6 +14,7 @@ import {
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
https: false,
enablePaidFeatures: false,
metricsConfig: {
containers: {
@@ -73,7 +74,6 @@ beforeEach(() => {
test("Should read the configuration file", () => {
const config: FileConfig = loadOrCreateConfig("dokploy");
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
"dokploy-service-app",
);
@@ -83,6 +83,7 @@ test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
https: true,
certificateType: "letsencrypt",
},
"example.com",

View File

@@ -0,0 +1,61 @@
import { describe, expect, test } from "vitest";
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
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/");
});
});

View File

@@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
})
.then(() => {
refetch();
toast.success("Preview deployments enabled");
toast.success(
checked
? "Preview deployments enabled"
: "Preview deployments disabled",
);
})
.catch((error) => {
toast.error(error.message);

View File

@@ -307,7 +307,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
>
{templates?.map((template) => (
<div
key={template.id}
key={template?.id}
className={cn(
"flex flex-col border rounded-lg overflow-hidden relative",
viewMode === "icon" && "h-[200px]",
@@ -315,7 +315,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
)}
>
<Badge className="absolute top-2 right-2" variant="blue">
{template.version}
{template?.version}
</Badge>
<div
className={cn(
@@ -324,21 +324,21 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
)}
>
<img
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template.id}/${template.logo}`}
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
className={cn(
"object-contain",
viewMode === "detailed" ? "size-24" : "size-16",
)}
alt={template.name}
alt={template?.name}
/>
<div className="flex flex-col items-center gap-2">
<span className="text-sm font-medium line-clamp-1">
{template.name}
{template?.name}
</span>
{viewMode === "detailed" &&
template.tags.length > 0 && (
template?.tags?.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{template.tags.map((tag) => (
{template?.tags?.map((tag) => (
<Badge
key={tag}
variant="green"
@@ -356,7 +356,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
{viewMode === "detailed" && (
<ScrollArea className="flex-1 p-6">
<div className="text-sm text-muted-foreground">
{template.description}
{template?.description}
</div>
</ScrollArea>
)}
@@ -372,25 +372,27 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
>
{viewMode === "detailed" && (
<div className="flex gap-2">
<Link
href={template.links.github}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<GithubIcon className="size-5" />
</Link>
{template.links.website && (
{template?.links?.github && (
<Link
href={template.links.website}
href={template?.links?.github}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<GithubIcon className="size-5" />
</Link>
)}
{template?.links?.website && (
<Link
href={template?.links?.website}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Globe className="size-5" />
</Link>
)}
{template.links.docs && (
{template?.links?.docs && (
<Link
href={template.links.docs}
href={template?.links?.docs}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
@@ -419,7 +421,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
</AlertDialogTitle>
<AlertDialogDescription>
This will create an application from the{" "}
{template.name} template and add it to your
{template?.name} template and add it to your
project.
</AlertDialogDescription>

View File

@@ -31,9 +31,14 @@ import { toast } from "sonner";
import { z } from "zod";
const AddProjectSchema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
name: z
.string()
.min(1, {
message: "Name is required",
})
.regex(/^[a-zA-Z]/, {
message: "Project name cannot start with a number",
}),
description: z.string().optional(),
});
@@ -97,18 +102,6 @@ export const HandleProject = ({ projectId }: Props) => {
);
});
};
// useEffect(() => {
// const getUsers = async () => {
// const users = await authClient.admin.listUsers({
// query: {
// limit: 100,
// },
// });
// console.log(users);
// };
// getUsers();
// });
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>

View File

@@ -115,7 +115,7 @@ export const ShowProjects = () => {
</span>
</div>
)}
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&

View File

@@ -36,6 +36,7 @@ const PasswordSchema = z.object({
password: z.string().min(8, {
message: "Password is required",
}),
issuer: z.string().optional(),
});
const PinSchema = z.object({
@@ -66,6 +67,7 @@ export const Enable2FA = () => {
try {
const { data: enableData, error } = await authClient.twoFactor.enable({
password: formData.password,
issuer: formData.issuer,
});
if (!enableData) {
@@ -217,6 +219,26 @@ export const Enable2FA = () => {
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Enter your issuer"
{...field}
/>
</FormControl>
<FormDescription>
Enter your password to enable 2FA
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"

View File

@@ -56,6 +56,7 @@ const randomImages = [
export const ProfileForm = () => {
const _utils = api.useUtils();
const { data, refetch, isLoading } = api.user.get.useQuery();
const {
mutateAsync,
isLoading: isUpdating,
@@ -84,12 +85,17 @@ export const ProfileForm = () => {
useEffect(() => {
if (data) {
form.reset({
email: data?.user?.email || "",
password: "",
image: data?.user?.image || "",
currentPassword: "",
});
form.reset(
{
email: data?.user?.email || "",
password: form.getValues("password") || "",
image: data?.user?.image || "",
currentPassword: form.getValues("currentPassword") || "",
},
{
keepValues: true,
},
);
if (data.user.email) {
generateSHA256Hash(data.user.email).then((hash) => {
@@ -97,8 +103,7 @@ export const ProfileForm = () => {
});
}
}
form.reset();
}, [form, form.reset, data]);
}, [form, data]);
const onSubmit = async (values: Profile) => {
await mutateAsync({
@@ -110,7 +115,12 @@ export const ProfileForm = () => {
.then(async () => {
await refetch();
toast.success("Profile Updated");
form.reset();
form.reset({
email: values.email,
password: "",
image: values.image,
currentPassword: "",
});
})
.catch(() => {
toast.error("Error updating the profile");

View File

@@ -9,6 +9,7 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -22,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon } from "lucide-react";
@@ -33,11 +35,19 @@ import { z } from "zod";
const addServerDomain = z
.object({
domain: z.string().min(1, { message: "URL is required" }),
domain: z.string(),
letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
})
.superRefine((data, ctx) => {
if (data.https && !data.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -61,15 +71,18 @@ export const WebDomain = () => {
domain: "",
certificateType: "none",
letsEncryptEmail: "",
https: false,
},
resolver: zodResolver(addServerDomain),
});
const https = form.watch("https");
useEffect(() => {
if (data) {
form.reset({
domain: data?.user?.host || "",
certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
https: data?.user?.https || false,
});
}
}, [form, form.reset, data]);
@@ -79,6 +92,7 @@ export const WebDomain = () => {
host: data.domain,
letsEncryptEmail: data.letsEncryptEmail,
certificateType: data.certificateType,
https: data.https,
})
.then(async () => {
await refetch();
@@ -155,44 +169,67 @@ export const WebDomain = () => {
/>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm w-full col-span-2">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
)}
<div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit">

View File

@@ -120,17 +120,6 @@ export const UserNav = () => {
Docker
</DropdownMenuItem>
)}
{data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
router.push("/dashboard/settings");
}}
>
Settings
</DropdownMenuItem>
)}
</>
) : (
<>

View File

@@ -0,0 +1 @@
ALTER TABLE "user_temp" ADD COLUMN "https" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -589,6 +589,13 @@
"when": 1743288371413,
"tag": "0083_parallel_stranger",
"breakpoints": true
},
{
"idx": 84,
"version": "7",
"when": 1743923992280,
"tag": "0084_thin_iron_lad",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.21.1",
"version": "v0.21.6",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -92,7 +92,7 @@
"adm-zip": "^0.5.14",
"ai": "^4.0.23",
"bcrypt": "5.1.1",
"better-auth": "1.2.4",
"better-auth": "1.2.6",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2",

View File

@@ -314,31 +314,43 @@ const Project = (
};
const applicationActions = {
start: api.application.start.useMutation(),
stop: api.application.stop.useMutation(),
move: api.application.move.useMutation(),
delete: api.application.delete.useMutation(),
};
const postgresActions = {
start: api.postgres.start.useMutation(),
stop: api.postgres.stop.useMutation(),
move: api.postgres.move.useMutation(),
delete: api.postgres.remove.useMutation(),
};
const mysqlActions = {
start: api.mysql.start.useMutation(),
stop: api.mysql.stop.useMutation(),
move: api.mysql.move.useMutation(),
delete: api.mysql.remove.useMutation(),
};
const mariadbActions = {
start: api.mariadb.start.useMutation(),
stop: api.mariadb.stop.useMutation(),
move: api.mariadb.move.useMutation(),
delete: api.mariadb.remove.useMutation(),
};
const redisActions = {
start: api.redis.start.useMutation(),
stop: api.redis.stop.useMutation(),
move: api.redis.move.useMutation(),
delete: api.redis.remove.useMutation(),
};
const mongoActions = {
start: api.mongo.start.useMutation(),
stop: api.mongo.stop.useMutation(),
move: api.mongo.move.useMutation(),
delete: api.mongo.remove.useMutation(),
};
@@ -348,7 +360,34 @@ const Project = (
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
await composeActions.start.mutateAsync({ composeId: serviceId });
const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
switch (service.type) {
case "application":
await applicationActions.start.mutateAsync({
applicationId: serviceId,
});
break;
case "compose":
await composeActions.start.mutateAsync({ composeId: serviceId });
break;
case "postgres":
await postgresActions.start.mutateAsync({ postgresId: serviceId });
break;
case "mysql":
await mysqlActions.start.mutateAsync({ mysqlId: serviceId });
break;
case "mariadb":
await mariadbActions.start.mutateAsync({ mariadbId: serviceId });
break;
case "redis":
await redisActions.start.mutateAsync({ redisId: serviceId });
break;
case "mongo":
await mongoActions.start.mutateAsync({ mongoId: serviceId });
break;
}
success++;
} catch (_error) {
toast.error(`Error starting service ${serviceId}`);
@@ -368,7 +407,34 @@ const Project = (
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
await composeActions.stop.mutateAsync({ composeId: serviceId });
const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
switch (service.type) {
case "application":
await applicationActions.stop.mutateAsync({
applicationId: serviceId,
});
break;
case "compose":
await composeActions.stop.mutateAsync({ composeId: serviceId });
break;
case "postgres":
await postgresActions.stop.mutateAsync({ postgresId: serviceId });
break;
case "mysql":
await mysqlActions.stop.mutateAsync({ mysqlId: serviceId });
break;
case "mariadb":
await mariadbActions.stop.mutateAsync({ mariadbId: serviceId });
break;
case "redis":
await redisActions.stop.mutateAsync({ redisId: serviceId });
break;
case "mongo":
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
break;
}
success++;
} catch (_error) {
toast.error(`Error stopping service ${serviceId}`);

View File

@@ -1,219 +0,0 @@
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DialogFooter } from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { zodResolver } from "@hookform/resolvers/zod";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { Settings } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import { type ReactElement, useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import superjson from "superjson";
import { z } from "zod";
const settings = z.object({
cleanCacheOnApplications: z.boolean(),
cleanCacheOnCompose: z.boolean(),
cleanCacheOnPreviews: z.boolean(),
});
type SettingsType = z.infer<typeof settings>;
const Page = () => {
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync, isLoading, isError, error } =
api.user.update.useMutation();
const form = useForm<SettingsType>({
defaultValues: {
cleanCacheOnApplications: false,
cleanCacheOnCompose: false,
cleanCacheOnPreviews: false,
},
resolver: zodResolver(settings),
});
useEffect(() => {
form.reset({
cleanCacheOnApplications: data?.user.cleanupCacheApplications || false,
cleanCacheOnCompose: data?.user.cleanupCacheOnCompose || false,
cleanCacheOnPreviews: data?.user.cleanupCacheOnPreviews || false,
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (values: SettingsType) => {
await mutateAsync({
cleanupCacheApplications: values.cleanCacheOnApplications,
cleanupCacheOnCompose: values.cleanCacheOnCompose,
cleanupCacheOnPreviews: values.cleanCacheOnPreviews,
})
.then(() => {
toast.success("Settings updated");
refetch();
})
.catch(() => {
toast.error("Something went wrong");
});
};
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Settings className="size-6 text-muted-foreground self-center" />
Settings
</CardTitle>
<CardDescription>Manage your Dokploy settings</CardDescription>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<Form {...form}>
<form
id="hook-form-add-security"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-2"
>
<FormField
control={form.control}
name="cleanCacheOnApplications"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Clean Cache on Applications</FormLabel>
<FormDescription>
Clean the cache after every application deployment
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cleanCacheOnPreviews"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Clean Cache on Previews</FormLabel>
<FormDescription>
Clean the cache after every preview deployment
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cleanCacheOnCompose"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Clean Cache on Compose</FormLabel>
<FormDescription>
Clean the cache after every compose deployment
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-add-security"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</CardContent>
</div>
</Card>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Server">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,58 @@
{
"settings.common.save": "Opslaan",
"settings.common.enterTerminal": "Terminal",
"settings.server.domain.title": "Server Domein",
"settings.server.domain.description": "Voeg een domein toe aan jouw server applicatie.",
"settings.server.domain.form.domain": "Domein",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email",
"settings.server.domain.form.certificate.label": "Certificaat Aanbieder",
"settings.server.domain.form.certificate.placeholder": "Select een certificaat",
"settings.server.domain.form.certificateOptions.none": "Geen",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Web Server",
"settings.server.webServer.description": "Herlaad of maak de web server schoon.",
"settings.server.webServer.actions": "Acties",
"settings.server.webServer.reload": "Herladen",
"settings.server.webServer.watchLogs": "Bekijk Logs",
"settings.server.webServer.updateServerIp": "Update de Server IP",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Bewerk Omgeving",
"settings.server.webServer.traefik.managePorts": "Extra Poort Mappings",
"settings.server.webServer.traefik.managePortsDescription": "Bewerk extra Poorten voor Traefik",
"settings.server.webServer.traefik.targetPort": "Doel Poort",
"settings.server.webServer.traefik.publishedPort": "Gepubliceerde Poort",
"settings.server.webServer.traefik.addPort": "Voeg Poort toe",
"settings.server.webServer.traefik.portsUpdated": "Poorten succesvol aangepast",
"settings.server.webServer.traefik.portsUpdateError": "Poorten niet succesvol aangepast",
"settings.server.webServer.traefik.publishMode": "Publiceer Mode",
"settings.server.webServer.storage.label": "Opslag",
"settings.server.webServer.storage.cleanUnusedImages": "Maak ongebruikte images schoon",
"settings.server.webServer.storage.cleanUnusedVolumes": "Maak ongebruikte volumes schoon",
"settings.server.webServer.storage.cleanStoppedContainers": "Maak gestopte containers schoon",
"settings.server.webServer.storage.cleanDockerBuilder": "Maak Docker Builder & Systeem schoon",
"settings.server.webServer.storage.cleanMonitoring": "Maak monitoor schoon",
"settings.server.webServer.storage.cleanAll": "Maak alles schoon",
"settings.profile.title": "Account",
"settings.profile.description": "Veramder details van account.",
"settings.profile.email": "Email",
"settings.profile.password": "Wachtwoord",
"settings.profile.avatar": "Profiel Icoon",
"settings.appearance.title": "Uiterlijk",
"settings.appearance.description": "Verander het thema van je dashboard.",
"settings.appearance.theme": "Thema",
"settings.appearance.themeDescription": "Selecteer een thema voor je dashboard.",
"settings.appearance.themes.light": "Licht",
"settings.appearance.themes.dark": "Donker",
"settings.appearance.themes.system": "Systeem",
"settings.appearance.language": "Taal",
"settings.appearance.languageDescription": "Selecteer een taal voor je dashboard.",
"settings.terminal.connectionSettings": "Verbindings instellingen",
"settings.terminal.ipAddress": "IP Address",
"settings.terminal.port": "Poort",
"settings.terminal.username": "Gebruikersnaam"
}

View File

@@ -33,6 +33,7 @@ import {
findApplicationById,
findProjectById,
getApplicationStats,
mechanizeDockerContainer,
readConfig,
readRemoteConfig,
removeDeployments,
@@ -132,28 +133,36 @@ export const applicationRouter = createTRPCRouter({
.input(apiReloadApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.project.organizationId !== ctx.session.activeOrganizationId
) {
try {
if (
application.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this application",
});
}
if (application.serverId) {
await stopServiceRemote(application.serverId, input.appName);
} else {
await stopService(input.appName);
}
await updateApplicationStatus(input.applicationId, "idle");
await mechanizeDockerContainer(application);
await updateApplicationStatus(input.applicationId, "done");
return true;
} catch (error) {
await updateApplicationStatus(input.applicationId, "error");
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this application",
code: "INTERNAL_SERVER_ERROR",
message: "Error reloading application",
cause: error,
});
}
if (application.serverId) {
await stopServiceRemote(application.serverId, input.appName);
} else {
await stopService(input.appName);
}
await updateApplicationStatus(input.applicationId, "idle");
if (application.serverId) {
await startServiceRemote(application.serverId, input.appName);
} else {
await startService(input.appName);
}
await updateApplicationStatus(input.applicationId, "done");
return true;
}),
delete: protectedProcedure

View File

@@ -31,7 +31,10 @@ import {
} from "@dokploy/server";
import { findDestinationById } from "@dokploy/server/services/destination";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import {
getS3Credentials,
normalizeS3Path,
} from "@dokploy/server/utils/backups/utils";
import {
execAsync,
execAsyncRemote,
@@ -257,7 +260,7 @@ export const backupRouter = createTRPCRouter({
const lastSlashIndex = input.search.lastIndexOf("/");
const baseDir =
lastSlashIndex !== -1
? input.search.slice(0, lastSlashIndex + 1)
? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1))
: "";
const searchTerm =
lastSlashIndex !== -1
@@ -270,7 +273,7 @@ export const backupRouter = createTRPCRouter({
let stdout = "";
if (input.serverId) {
const result = await execAsyncRemote(listCommand, input.serverId);
const result = await execAsyncRemote(input.serverId, listCommand);
stdout = result.stdout;
} else {
const result = await execAsync(listCommand);

View File

@@ -10,8 +10,8 @@ import {
import {
IS_CLOUD,
createRegistry,
execAsync,
execAsyncRemote,
execFileAsync,
findRegistryById,
removeRegistry,
updateRegistry,
@@ -83,7 +83,13 @@ export const registryRouter = createTRPCRouter({
.input(apiTestRegistry)
.mutation(async ({ input }) => {
try {
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
const args = [
"login",
input.registryUrl,
"--username",
input.username,
"--password-stdin",
];
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -93,9 +99,14 @@ export const registryRouter = createTRPCRouter({
}
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand);
await execAsyncRemote(
input.serverId,
`echo ${input.password} | docker ${args.join(" ")}`,
);
} else {
await execAsync(loginCommand);
await execFileAsync("docker", args, {
input: Buffer.from(input.password).toString(),
});
}
return true;

View File

@@ -184,6 +184,7 @@ export const settingsRouter = createTRPCRouter({
letsEncryptEmail: input.letsEncryptEmail,
}),
certificateType: input.certificateType,
https: input.https,
});
if (!user) {

View File

@@ -46,8 +46,8 @@ void app.prepare().then(async () => {
await initializeNetwork();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
await initCronJobs();
await migration();
await initCronJobs();
await sendDokployRestartNotifications();
}

View File

@@ -1,9 +1,9 @@
import type http from "node:http";
import { validateRequest } from "@dokploy/server/index";
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "../api/root";
import { createTRPCContext } from "../api/trpc";
import { validateRequest } from "@dokploy/server/lib/auth";
export const setupDrawerLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -13,11 +13,13 @@ export const setupDrawerLogsWebSocketServer = (
path: "/drawer-logs",
});
// Set up tRPC WebSocket handler
applyWSSHandler({
wss: wssTerm,
router: appRouter,
createContext: createTRPCContext as any,
});
server.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url || "", `http://${req.headers.host}`);

View File

@@ -36,11 +36,11 @@
"@ai-sdk/mistral": "^1.0.6",
"@ai-sdk/openai": "^1.0.12",
"@ai-sdk/openai-compatible": "^0.0.13",
"@better-auth/utils": "0.2.3",
"@better-auth/utils": "0.2.4",
"@oslojs/encoding": "1.1.0",
"@oslojs/crypto": "1.0.1",
"drizzle-dbml-generator": "0.10.0",
"better-auth": "1.2.4",
"better-auth": "1.2.6",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.0.4",
"@react-email/components": "^0.0.21",

View File

@@ -50,6 +50,7 @@ export const users_temp = pgTable("user_temp", {
// Admin
serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"),
https: boolean("https").notNull().default(false),
host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"),
@@ -202,10 +203,12 @@ export const apiAssignDomain = createSchema
host: true,
certificateType: true,
letsEncryptEmail: true,
https: true,
})
.required()
.partial({
letsEncryptEmail: true,
https: true,
});
export const apiUpdateDockerCleanup = createSchema

View File

@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import type { Schema } from "./index";
import {
generateBase64,
@@ -70,7 +71,7 @@ function processValue(
schema: Schema,
): string {
// First replace utility functions
let processedValue = value.replace(/\${([^}]+)}/g, (match, varName) => {
let processedValue = value?.replace(/\${([^}]+)}/g, (match, varName) => {
// Handle utility functions
if (varName === "domain") {
return generateRandomDomain(schema);
@@ -117,6 +118,14 @@ function processValue(
return generateJwt(length);
}
if (varName === "username") {
return faker.internet.userName().toLowerCase();
}
if (varName === "email") {
return faker.internet.email().toLowerCase();
}
// If not a utility function, try to get from variables
return variables[varName] || match;
});
@@ -177,7 +186,14 @@ export function processDomains(
variables: Record<string, string>,
schema: Schema,
): Template["domains"] {
if (!template?.config?.domains) return [];
if (
!template?.config?.domains ||
template.config.domains.length === 0 ||
template.config.domains.every((domain) => !domain.serviceName)
) {
return [];
}
return template?.config?.domains?.map((domain: DomainConfig) => ({
...domain,
host: domain.host
@@ -194,7 +210,9 @@ export function processEnvVars(
variables: Record<string, string>,
schema: Schema,
): Template["envs"] {
if (!template?.config?.env) return [];
if (!template?.config?.env || Object.keys(template.config.env).length === 0) {
return [];
}
// Handle array of env vars
if (Array.isArray(template.config.env)) {
@@ -233,7 +251,13 @@ export function processMounts(
variables: Record<string, string>,
schema: Schema,
): Template["mounts"] {
if (!template?.config?.mounts) return [];
if (
!template?.config?.mounts ||
template.config.mounts.length === 0 ||
template.config.mounts.every((mount) => !mount.filePath && !mount.content)
) {
return [];
}
return template?.config?.mounts?.map((mount: MountConfig) => ({
filePath: processValue(mount.filePath, variables, schema),

View File

@@ -2,7 +2,6 @@ import path from "node:path";
import { getAllServers } from "@dokploy/server/services/server";
import { scheduleJob } from "node-schedule";
import { db } from "../../db/index";
import { findAdmin } from "../../services/admin";
import {
cleanUpDockerBuilder,
cleanUpSystemPrune,
@@ -14,13 +13,24 @@ import { getS3Credentials, scheduleBackup } from "./utils";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import { startLogCleanup } from "../access-log/handler";
import { member } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
export const initCronJobs = async () => {
console.log("Setting up cron jobs....");
const admin = await findAdmin();
const admin = await db.query.member.findFirst({
where: eq(member.role, "owner"),
with: {
user: true,
},
});
if (admin?.user.enableDockerCleanup) {
if (!admin) {
return;
}
if (admin.user.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running docker cleanup`,
@@ -96,8 +106,8 @@ export const keepLatestNBackups = async (
backup.prefix,
);
// --include "*.sql.gz" ensures nothing else other than the db backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*.sql.gz" ${backupFilesPath}`;
// --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`;
// when we pipe the above command with this one, we only get the list of files we want to delete
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
// this command deletes the files

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Mariadb } from "@dokploy/server/services/mariadb";
import { findProjectById } from "@dokploy/server/services/project";
@@ -8,7 +7,7 @@ import {
} from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials } from "./utils";
import { getS3Credentials, normalizeS3Path } from "./utils";
export const runMariadbBackup = async (
mariadb: Mariadb,
@@ -19,7 +18,7 @@ export const runMariadbBackup = async (
const { prefix, database } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = path.join(prefix, backupFileName);
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Mongo } from "@dokploy/server/services/mongo";
import { findProjectById } from "@dokploy/server/services/project";
@@ -8,7 +7,7 @@ import {
} from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials } from "./utils";
import { getS3Credentials, normalizeS3Path } from "./utils";
// mongodb://mongo:Bqh7AQl-PRbnBu@localhost:27017/?tls=false&directConnection=true
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
@@ -17,7 +16,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const { prefix, database } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.dump.gz`;
const bucketDestination = path.join(prefix, backupFileName);
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { MySql } from "@dokploy/server/services/mysql";
import { findProjectById } from "@dokploy/server/services/project";
@@ -8,7 +7,7 @@ import {
} from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials } from "./utils";
import { getS3Credentials, normalizeS3Path } from "./utils";
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const { appName, databaseRootPassword, projectId, name } = mysql;
@@ -16,7 +15,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const { prefix, database } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = path.join(prefix, backupFileName);
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Postgres } from "@dokploy/server/services/postgres";
import { findProjectById } from "@dokploy/server/services/project";
@@ -8,7 +7,7 @@ import {
} from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials } from "./utils";
import { getS3Credentials, normalizeS3Path } from "./utils";
export const runPostgresBackup = async (
postgres: Postgres,
@@ -20,7 +19,7 @@ export const runPostgresBackup = async (
const { prefix, database } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = path.join(prefix, backupFileName);
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;

View File

@@ -36,6 +36,13 @@ export const removeScheduleBackup = (backupId: string) => {
currentJob?.cancel();
};
export const normalizeS3Path = (prefix: string) => {
// Trim whitespace and remove leading/trailing slashes
const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, "");
// Return empty string if prefix is empty, otherwise append trailing slash
return normalizedPrefix ? `${normalizedPrefix}/` : "";
};
export const getS3Credentials = (destination: Destination) => {
const { accessKey, secretAccessKey, region, endpoint, provider } =
destination;

View File

@@ -1,6 +1,6 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import { execAsync } from "../process/execAsync";
import { getS3Credentials } from "./utils";
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";
@@ -18,7 +18,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
const { BASE_PATH } = paths();
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
const backupFileName = `webserver-backup-${timestamp}.zip`;
const s3Path = `:s3:${destination.bucket}/${backup.prefix}${backupFileName}`;
const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
try {
await execAsync(`mkdir -p ${tempDir}/filesystem`);
@@ -29,7 +29,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`);
await execAsync(
`cd ${tempDir} && zip -r ${backupFileName} database.sql filesystem/`,
`cd ${tempDir} && zip -r ${backupFileName} database.sql filesystem/ > /dev/null 2>&1`,
);
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`;

View File

@@ -84,7 +84,7 @@ export const buildRailpack = async (
for (const envVar of envVariables) {
const [key, value] = envVar.split("=");
if (key && value) {
buildArgs.push("--secret", `id=${key},env=${key}`);
buildArgs.push("--secret", `id=${key},env='${key}'`);
env[key] = value;
}
}
@@ -132,7 +132,7 @@ export const getRailpackCommand = (
];
for (const env of envVariables) {
prepareArgs.push("--env", env);
prepareArgs.push("--env", `'${env}'`);
}
// Calculate secrets hash for layer invalidation
@@ -164,7 +164,7 @@ export const getRailpackCommand = (
for (const envVar of envVariables) {
const [key, value] = envVar.split("=");
if (key && value) {
buildArgs.push("--secret", `id=${key},env=${key}`);
buildArgs.push("--secret", `id=${key},env='${key}'`);
exportEnvs.push(`export ${key}=${value}`);
}
}

View File

@@ -25,6 +25,12 @@ export const buildStatic = async (
].join("\n"),
);
createFile(
buildAppDirectory,
".dockerignore",
[".git", ".env", "Dockerfile", ".dockerignore"].join("\n"),
);
await buildCustomDocker(
{
...application,

View File

@@ -249,6 +249,11 @@ export const addDomainToCompose = async (
labels.unshift("traefik.enable=true");
}
labels.unshift(...httpLabels);
if (!compose.isolatedDeployment) {
if (!labels.includes("traefik.docker.network=dokploy-network")) {
labels.unshift("traefik.docker.network=dokploy-network");
}
}
}
if (!compose.isolatedDeployment) {

View File

@@ -1,9 +1,48 @@
import { exec } from "node:child_process";
import { exec, execFile } from "node:child_process";
import util from "node:util";
import { findServerById } from "@dokploy/server/services/server";
import { Client } from "ssh2";
export const execAsync = util.promisify(exec);
export const execFileAsync = async (
command: string,
args: string[],
options: { input?: string } = {},
): Promise<{ stdout: string; stderr: string }> => {
const child = execFile(command, args);
if (options.input && child.stdin) {
child.stdin.write(options.input);
child.stdin.end();
}
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(
new Error(`Command failed with code ${code}. Stderr: ${stderr}`),
);
}
});
child.on("error", reject);
});
};
export const execAsyncRemote = async (
serverId: string | null,
command: string,

View File

@@ -45,7 +45,7 @@ export const restoreWebServerBackup = async (
// Extract backup
emit("Extracting backup...");
await execAsync(`cd ${tempDir} && unzip ${backupFile}`);
await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`);
// Restore filesystem first
emit("Restoring filesystem...");

View File

@@ -3,7 +3,11 @@ import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { User } from "@dokploy/server/services/user";
import { dump, load } from "js-yaml";
import { loadOrCreateConfig, writeTraefikConfig } from "./application";
import {
loadOrCreateConfig,
removeTraefikConfig,
writeTraefikConfig,
} from "./application";
import type { FileConfig } from "./file-types";
import type { MainTraefikConfig } from "./types";
@@ -11,32 +15,62 @@ export const updateServerTraefik = (
user: User | null,
newHost: string | null,
) => {
const { https, certificateType } = user || {};
const appName = "dokploy";
const config: FileConfig = loadOrCreateConfig(appName);
config.http = config.http || { routers: {}, services: {} };
config.http.routers = config.http.routers || {};
config.http.services = config.http.services || {};
const currentRouterConfig = config.http.routers[`${appName}-router-app`];
const currentRouterConfig = config.http.routers[`${appName}-router-app`] || {
rule: `Host(\`${newHost}\`)`,
service: `${appName}-service-app`,
entryPoints: ["web"],
};
config.http.routers[`${appName}-router-app`] = currentRouterConfig;
if (currentRouterConfig && newHost) {
currentRouterConfig.rule = `Host(\`${newHost}\`)`;
config.http.services = {
...config.http.services,
[`${appName}-service-app`]: {
loadBalancer: {
servers: [
{
url: `http://dokploy:${process.env.PORT || 3000}`,
},
],
passHostHeader: true,
},
},
};
if (user?.certificateType === "letsencrypt") {
if (https) {
currentRouterConfig.middlewares = ["redirect-to-https"];
if (certificateType === "letsencrypt") {
config.http.routers[`${appName}-router-app-secure`] = {
...currentRouterConfig,
rule: `Host(\`${newHost}\`)`,
service: `${appName}-service-app`,
entryPoints: ["websecure"],
tls: { certResolver: "letsencrypt" },
};
currentRouterConfig.middlewares = ["redirect-to-https"];
} else {
delete config.http.routers[`${appName}-router-app-secure`];
currentRouterConfig.middlewares = [];
config.http.routers[`${appName}-router-app-secure`] = {
rule: `Host(\`${newHost}\`)`,
service: `${appName}-service-app`,
entryPoints: ["websecure"],
};
}
} else {
delete config.http.routers[`${appName}-router-app-secure`];
currentRouterConfig.middlewares = [];
}
writeTraefikConfig(config, appName);
if (newHost) {
writeTraefikConfig(config, appName);
} else {
removeTraefikConfig(appName);
}
};
export const updateLetsEncryptEmail = (newEmail: string | null) => {

88
pnpm-lock.yaml generated
View File

@@ -17,7 +17,7 @@ importers:
version: 1.9.4
'@commitlint/cli':
specifier: ^19.3.0
version: 19.3.0(@types/node@18.19.42)(typescript@5.7.2)
version: 19.3.0(@types/node@18.19.42)(typescript@5.8.3)
'@commitlint/config-conventional':
specifier: ^19.2.2
version: 19.2.2
@@ -266,8 +266,8 @@ importers:
specifier: 5.1.1
version: 5.1.1(encoding@0.1.13)
better-auth:
specifier: 1.2.4
version: 1.2.4(typescript@5.5.3)
specifier: 1.2.6
version: 1.2.6
bl:
specifier: 6.0.11
version: 6.0.11
@@ -607,8 +607,8 @@ importers:
specifier: ^0.0.13
version: 0.0.13(zod@3.23.8)
'@better-auth/utils':
specifier: 0.2.3
version: 0.2.3
specifier: 0.2.4
version: 0.2.4
'@faker-js/faker':
specifier: ^8.4.1
version: 8.4.1
@@ -640,8 +640,8 @@ importers:
specifier: 5.1.1
version: 5.1.1(encoding@0.1.13)
better-auth:
specifier: 1.2.4
version: 1.2.4(typescript@5.5.3)
specifier: 1.2.6
version: 1.2.6
bl:
specifier: 6.0.11
version: 6.0.11
@@ -924,11 +924,11 @@ packages:
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
'@better-auth/utils@0.2.3':
resolution: {integrity: sha512-Ap1GaSmo6JYhJhxJOpUB0HobkKPTNzfta+bLV89HfpyCAHN7p8ntCrmNFHNAVD0F6v0mywFVEUg1FUhNCc81Rw==}
'@better-auth/utils@0.2.4':
resolution: {integrity: sha512-ayiX87Xd5sCHEplAdeMgwkA0FgnXsEZBgDn890XHHwSWNqqRZDYOq3uj2Ei2leTv1I2KbG5HHn60Ah1i2JWZjQ==}
'@better-fetch/fetch@1.1.15':
resolution: {integrity: sha512-0Bl8YYj1f8qCTNHeSn5+1DWv2hy7rLBrQ8rS8Y9XYloiwZEfc3k4yspIG0llRxafxqhGCwlGRg+F8q1HZRCMXA==}
'@better-fetch/fetch@1.1.18':
resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==}
'@biomejs/biome@1.9.4':
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
@@ -3836,11 +3836,11 @@ packages:
before-after-hook@2.2.3:
resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==}
better-auth@1.2.4:
resolution: {integrity: sha512-/ZK2jbUjm8JwdeCLFrUWUBmexPyI9PkaLVXWLWtN60sMDHTY8B5G72wcHglo1QMFBaw4G0qFkP5ayl9k6XfDaA==}
better-auth@1.2.6:
resolution: {integrity: sha512-RVy6nfNCXpohx49zP2ChUO3zN0nvz5UXuETJIhWU+dshBKpFMk4P4hAQauM3xqTJdd9hfeB5y+segmG1oYGTJQ==}
better-call@1.0.3:
resolution: {integrity: sha512-DUKImKoDIy5UtCvQbHTg0wuBRse6gu1Yvznn7+1B3I5TeY8sclRPFce0HI+4WF2bcb+9PqmkET8nXZubrHQh9A==}
better-call@1.0.7:
resolution: {integrity: sha512-p5kEthErx3HsW9dCCvvEx+uuEdncn0ZrlqrOG3TkR1aVYgynpwYbTVU90nY8/UwfMhROzqZWs8vryainSQxrNg==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
@@ -7093,8 +7093,8 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
typescript@5.7.2:
resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==}
typescript@5.8.3:
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
engines: {node: '>=14.17'}
hasBin: true
@@ -7210,14 +7210,6 @@ packages:
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
valibot@1.0.0-beta.15:
resolution: {integrity: sha512-BKy8XosZkDHWmYC+cJG74LBzP++Gfntwi33pP3D3RKztz2XV9jmFWnkOi21GoqARP8wAWARwhV6eTr1JcWzjGw==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
vfile-message@4.0.2:
resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==}
@@ -7567,11 +7559,12 @@ snapshots:
'@balena/dockerignore@1.0.2': {}
'@better-auth/utils@0.2.3':
'@better-auth/utils@0.2.4':
dependencies:
typescript: 5.8.3
uncrypto: 0.1.3
'@better-fetch/fetch@1.1.15': {}
'@better-fetch/fetch@1.1.18': {}
'@biomejs/biome@1.9.4':
optionalDependencies:
@@ -7678,11 +7671,11 @@ snapshots:
style-mod: 4.1.2
w3c-keyname: 2.2.8
'@commitlint/cli@19.3.0(@types/node@18.19.42)(typescript@5.7.2)':
'@commitlint/cli@19.3.0(@types/node@18.19.42)(typescript@5.8.3)':
dependencies:
'@commitlint/format': 19.3.0
'@commitlint/lint': 19.2.2
'@commitlint/load': 19.2.0(@types/node@18.19.42)(typescript@5.7.2)
'@commitlint/load': 19.2.0(@types/node@18.19.42)(typescript@5.8.3)
'@commitlint/read': 19.2.1
'@commitlint/types': 19.0.3
execa: 8.0.1
@@ -7729,15 +7722,15 @@ snapshots:
'@commitlint/rules': 19.0.3
'@commitlint/types': 19.0.3
'@commitlint/load@19.2.0(@types/node@18.19.42)(typescript@5.7.2)':
'@commitlint/load@19.2.0(@types/node@18.19.42)(typescript@5.8.3)':
dependencies:
'@commitlint/config-validator': 19.0.3
'@commitlint/execute-rule': 19.0.0
'@commitlint/resolve-extends': 19.1.0
'@commitlint/types': 19.0.3
chalk: 5.3.0
cosmiconfig: 9.0.0(typescript@5.7.2)
cosmiconfig-typescript-loader: 5.0.0(@types/node@18.19.42)(cosmiconfig@9.0.0(typescript@5.7.2))(typescript@5.7.2)
cosmiconfig: 9.0.0(typescript@5.8.3)
cosmiconfig-typescript-loader: 5.0.0(@types/node@18.19.42)(cosmiconfig@9.0.0(typescript@5.8.3))(typescript@5.8.3)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
@@ -10547,27 +10540,24 @@ snapshots:
before-after-hook@2.2.3: {}
better-auth@1.2.4(typescript@5.5.3):
better-auth@1.2.6:
dependencies:
'@better-auth/utils': 0.2.3
'@better-fetch/fetch': 1.1.15
'@better-auth/utils': 0.2.4
'@better-fetch/fetch': 1.1.18
'@noble/ciphers': 0.6.0
'@noble/hashes': 1.7.1
'@simplewebauthn/browser': 13.1.0
'@simplewebauthn/server': 13.1.1
better-call: 1.0.3
better-call: 1.0.7
defu: 6.1.4
jose: 5.9.6
kysely: 0.27.6
nanostores: 0.11.3
valibot: 1.0.0-beta.15(typescript@5.5.3)
zod: 3.24.1
transitivePeerDependencies:
- typescript
better-call@1.0.3:
better-call@1.0.7:
dependencies:
'@better-fetch/fetch': 1.1.15
'@better-fetch/fetch': 1.1.18
rou3: 0.5.1
set-cookie-parser: 2.7.1
uncrypto: 0.1.3
@@ -10942,21 +10932,21 @@ snapshots:
core-js@3.39.0: {}
cosmiconfig-typescript-loader@5.0.0(@types/node@18.19.42)(cosmiconfig@9.0.0(typescript@5.7.2))(typescript@5.7.2):
cosmiconfig-typescript-loader@5.0.0(@types/node@18.19.42)(cosmiconfig@9.0.0(typescript@5.8.3))(typescript@5.8.3):
dependencies:
'@types/node': 18.19.42
cosmiconfig: 9.0.0(typescript@5.7.2)
cosmiconfig: 9.0.0(typescript@5.8.3)
jiti: 1.21.6
typescript: 5.7.2
typescript: 5.8.3
cosmiconfig@9.0.0(typescript@5.7.2):
cosmiconfig@9.0.0(typescript@5.8.3):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.0
js-yaml: 4.1.0
parse-json: 5.2.0
optionalDependencies:
typescript: 5.7.2
typescript: 5.8.3
cpu-features@0.0.10:
dependencies:
@@ -14171,7 +14161,7 @@ snapshots:
typescript@5.5.3: {}
typescript@5.7.2: {}
typescript@5.8.3: {}
ufo@1.5.4: {}
@@ -14292,10 +14282,6 @@ snapshots:
v8-compile-cache-lib@3.0.1:
optional: true
valibot@1.0.0-beta.15(typescript@5.5.3):
optionalDependencies:
typescript: 5.5.3
vfile-message@4.0.2:
dependencies:
'@types/unist': 3.0.3