Skip to content

Commit e102876

Browse files
authored
Merge pull request #2959 from Harikrishnan1367709/Easier-Ways-to-Upload-Files-to-a-Docker-Container-#2920
feat: Add web UI file upload to Docker containers (#2920)
2 parents 2e8e2dc + 4c06a72 commit e102876

6 files changed

Lines changed: 280 additions & 3 deletions

File tree

apps/dokploy/components/dashboard/docker/show/columns.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ShowContainerConfig } from "../config/show-container-config";
1212
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
1313
import { RemoveContainerDialog } from "../remove/remove-container";
1414
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
15+
import { UploadFileModal } from "../upload/upload-file-modal";
1516
import type { Container } from "./show-containers";
1617

1718
export const columns: ColumnDef<Container>[] = [
@@ -128,6 +129,12 @@ export const columns: ColumnDef<Container>[] = [
128129
>
129130
Terminal
130131
</DockerTerminalModal>
132+
<UploadFileModal
133+
containerId={container.containerId}
134+
serverId={container.serverId || undefined}
135+
>
136+
Upload File
137+
</UploadFileModal>
131138
<RemoveContainerDialog
132139
containerId={container.containerId}
133140
serverId={container.serverId ?? undefined}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
2+
import { Upload } from "lucide-react";
3+
import { useState } from "react";
4+
import { useForm } from "react-hook-form";
5+
import { toast } from "sonner";
6+
import { Button } from "@/components/ui/button";
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogDescription,
11+
DialogFooter,
12+
DialogHeader,
13+
DialogTitle,
14+
DialogTrigger,
15+
} from "@/components/ui/dialog";
16+
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
17+
import { Dropzone } from "@/components/ui/dropzone";
18+
import {
19+
Form,
20+
FormControl,
21+
FormField,
22+
FormItem,
23+
FormLabel,
24+
FormMessage,
25+
} from "@/components/ui/form";
26+
import { Input } from "@/components/ui/input";
27+
import { api } from "@/utils/api";
28+
import {
29+
uploadFileToContainerSchema,
30+
type UploadFileToContainer,
31+
} from "@/utils/schema";
32+
33+
interface Props {
34+
containerId: string;
35+
serverId?: string;
36+
children?: React.ReactNode;
37+
}
38+
39+
export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
40+
const [open, setOpen] = useState(false);
41+
42+
const { mutateAsync: uploadFile, isPending: isLoading } =
43+
api.docker.uploadFileToContainer.useMutation({
44+
onSuccess: () => {
45+
toast.success("File uploaded successfully");
46+
setOpen(false);
47+
form.reset();
48+
},
49+
onError: (error) => {
50+
toast.error(error.message || "Failed to upload file to container");
51+
},
52+
});
53+
54+
const form = useForm({
55+
resolver: zodResolver(uploadFileToContainerSchema),
56+
defaultValues: {
57+
containerId,
58+
destinationPath: "/",
59+
serverId: serverId || undefined,
60+
},
61+
});
62+
63+
const file = form.watch("file");
64+
65+
const onSubmit = async (values: UploadFileToContainer) => {
66+
if (!values.file) {
67+
toast.error("Please select a file to upload");
68+
return;
69+
}
70+
71+
const formData = new FormData();
72+
formData.append("containerId", values.containerId);
73+
formData.append("file", values.file);
74+
formData.append("destinationPath", values.destinationPath);
75+
if (values.serverId) {
76+
formData.append("serverId", values.serverId);
77+
}
78+
79+
await uploadFile(formData);
80+
};
81+
82+
return (
83+
<Dialog open={open} onOpenChange={setOpen}>
84+
<DialogTrigger asChild>
85+
<DropdownMenuItem
86+
className="w-full cursor-pointer space-x-3"
87+
onSelect={(e) => e.preventDefault()}
88+
>
89+
{children}
90+
</DropdownMenuItem>
91+
</DialogTrigger>
92+
<DialogContent className="sm:max-w-2xl">
93+
<DialogHeader>
94+
<DialogTitle className="flex items-center gap-2">
95+
<Upload className="h-5 w-5" />
96+
Upload File to Container
97+
</DialogTitle>
98+
<DialogDescription>
99+
Upload a file directly into the container's filesystem
100+
</DialogDescription>
101+
</DialogHeader>
102+
103+
<Form {...form}>
104+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
105+
<FormField
106+
control={form.control}
107+
name="destinationPath"
108+
render={({ field }) => (
109+
<FormItem>
110+
<FormLabel>Destination Path</FormLabel>
111+
<FormControl>
112+
<Input
113+
{...field}
114+
placeholder="/path/to/file"
115+
className="font-mono"
116+
/>
117+
</FormControl>
118+
<FormMessage />
119+
<p className="text-xs text-muted-foreground">
120+
Enter the full path where the file should be uploaded in the
121+
container (e.g., /app/config.json)
122+
</p>
123+
</FormItem>
124+
)}
125+
/>
126+
127+
<FormField
128+
control={form.control}
129+
name="file"
130+
render={({ field }) => (
131+
<FormItem>
132+
<FormLabel>File</FormLabel>
133+
<FormControl>
134+
<Dropzone
135+
{...field}
136+
dropMessage="Drop file here or click to browse"
137+
onChange={(files) => {
138+
if (files && files.length > 0) {
139+
field.onChange(files[0]);
140+
} else {
141+
field.onChange(null);
142+
}
143+
}}
144+
/>
145+
</FormControl>
146+
<FormMessage />
147+
{file instanceof File && (
148+
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
149+
<span className="text-sm text-muted-foreground flex-1">
150+
{file.name} ({(file.size / 1024).toFixed(2)} KB)
151+
</span>
152+
<Button
153+
type="button"
154+
variant="ghost"
155+
size="sm"
156+
onClick={() => field.onChange(null)}
157+
>
158+
Remove
159+
</Button>
160+
</div>
161+
)}
162+
</FormItem>
163+
)}
164+
/>
165+
166+
<DialogFooter>
167+
<Button
168+
type="button"
169+
variant="outline"
170+
onClick={() => setOpen(false)}
171+
>
172+
Cancel
173+
</Button>
174+
<Button
175+
type="submit"
176+
isLoading={isLoading}
177+
disabled={!file || isLoading}
178+
>
179+
Upload File
180+
</Button>
181+
</DialogFooter>
182+
</form>
183+
</Form>
184+
</DialogContent>
185+
</Dialog>
186+
);
187+
};

apps/dokploy/components/ui/dropzone.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
5656
onDrop={handleDrop}
5757
onClick={handleButtonClick}
5858
>
59-
<div className="flex items-center justify-center text-muted-foreground">
60-
<span className="font-medium text-xl flex items-center gap-2">
61-
<FolderIcon className="size-6 text-muted-foreground" />
59+
<div className="flex flex-col items-center justify-center text-muted-foreground">
60+
<FolderIcon className="size-6 text-muted-foreground" />
61+
<span className="font-medium text-xl text-center">
6262
{dropMessage}
6363
</span>
6464
<Input

apps/dokploy/server/api/routers/docker.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import {
88
getContainersByAppNameMatch,
99
getServiceContainersByAppName,
1010
getStackContainersByAppName,
11+
uploadFileToContainer,
1112
} from "@dokploy/server";
1213
import { TRPCError } from "@trpc/server";
1314
import { z } from "zod";
1415
import { audit } from "@/server/api/utils/audit";
16+
import { uploadFileToContainerSchema } from "@/utils/schema";
1517
import { createTRPCRouter, withPermission } from "../trpc";
1618

1719
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
@@ -176,4 +178,37 @@ export const dockerRouter = createTRPCRouter({
176178
}
177179
return await getServiceContainersByAppName(input.appName, input.serverId);
178180
}),
181+
182+
uploadFileToContainer: withPermission("docker", "read")
183+
.input(uploadFileToContainerSchema)
184+
.mutation(async ({ input, ctx }) => {
185+
if (input.serverId) {
186+
const server = await findServerById(input.serverId);
187+
if (server.organizationId !== ctx.session?.activeOrganizationId) {
188+
throw new TRPCError({ code: "UNAUTHORIZED" });
189+
}
190+
}
191+
192+
const file = input.file;
193+
if (!(file instanceof File)) {
194+
throw new TRPCError({
195+
code: "BAD_REQUEST",
196+
message: "Invalid file provided",
197+
});
198+
}
199+
200+
// Convert File to Buffer
201+
const arrayBuffer = await file.arrayBuffer();
202+
const fileBuffer = Buffer.from(arrayBuffer);
203+
204+
await uploadFileToContainer(
205+
input.containerId,
206+
fileBuffer,
207+
file.name,
208+
input.destinationPath,
209+
input.serverId || null,
210+
);
211+
212+
return { success: true, message: "File uploaded successfully" };
213+
}),
179214
});

apps/dokploy/utils/schema.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,15 @@ export const uploadFileSchema = zfd.formData({
1717
});
1818

1919
export type UploadFile = z.infer<typeof uploadFileSchema>;
20+
21+
export const uploadFileToContainerSchema = zfd.formData({
22+
containerId: z
23+
.string()
24+
.min(1)
25+
.regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container ID"),
26+
file: zfd.file(),
27+
destinationPath: z.string().min(1),
28+
serverId: z.string().optional(),
29+
});
30+
31+
export type UploadFileToContainer = z.infer<typeof uploadFileToContainerSchema>;

packages/server/src/services/docker.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,3 +505,39 @@ export const getApplicationInfo = async (
505505
return appArray;
506506
} catch {}
507507
};
508+
509+
export const uploadFileToContainer = async (
510+
containerId: string,
511+
fileBuffer: Buffer,
512+
fileName: string,
513+
destinationPath: string,
514+
serverId?: string | null,
515+
): Promise<void> => {
516+
const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
517+
if (!containerIdRegex.test(containerId)) {
518+
throw new Error("Invalid container ID");
519+
}
520+
521+
// Ensure destination path starts with /
522+
const normalizedPath = destinationPath.startsWith("/")
523+
? destinationPath
524+
: `/${destinationPath}`;
525+
526+
const base64Content = fileBuffer.toString("base64");
527+
const tempFileName = `dokploy-upload-${Date.now()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
528+
const tempPath = `/tmp/${tempFileName}`;
529+
530+
const command = `echo '${base64Content}' | base64 -d > "${tempPath}" && docker cp "${tempPath}" "${containerId}:${normalizedPath}" ; rm -f "${tempPath}"`;
531+
532+
try {
533+
if (serverId) {
534+
await execAsyncRemote(serverId, command);
535+
} else {
536+
await execAsync(command);
537+
}
538+
} catch (error) {
539+
throw new Error(
540+
`Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`,
541+
);
542+
}
543+
};

0 commit comments

Comments
 (0)