Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/dokploy/components/dashboard/docker/show/colums.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import { UploadFileModal } from "../upload/upload-file-modal";
import type { Container } from "./show-containers";

export const columns: ColumnDef<Container>[] = [
Expand Down Expand Up @@ -127,6 +128,12 @@ export const columns: ColumnDef<Container>[] = [
>
Terminal
</DockerTerminalModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Upload File
</UploadFileModal>
</DropdownMenuContent>
</DropdownMenu>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Upload } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Dropzone } from "@/components/ui/dropzone";
import { api } from "@/utils/api";
import {
uploadFileToContainerSchema,
type UploadFileToContainer,
} from "@/utils/schema";

interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
}

export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
const [open, setOpen] = useState(false);

const { mutateAsync: uploadFile, isLoading } =
api.docker.uploadFileToContainer.useMutation({
onSuccess: () => {
toast.success("File uploaded successfully");
setOpen(false);
form.reset();
},
onError: (error) => {
toast.error(error.message || "Failed to upload file to container");
},
});

const form = useForm<UploadFileToContainer>({
resolver: zodResolver(uploadFileToContainerSchema),
defaultValues: {
containerId,
destinationPath: "/",
serverId: serverId || undefined,
},
});

const file = form.watch("file");

const onSubmit = async (values: UploadFileToContainer) => {
if (!values.file) {
toast.error("Please select a file to upload");
return;
}

const formData = new FormData();
formData.append("containerId", values.containerId);
formData.append("file", values.file);
formData.append("destinationPath", values.destinationPath);
if (values.serverId) {
formData.append("serverId", values.serverId);
}

await uploadFile(formData);
};

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload File to Container
</DialogTitle>
<DialogDescription>
Upload a file directly into the container's filesystem
</DialogDescription>
</DialogHeader>

<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="destinationPath"
render={({ field }) => (
<FormItem>
<FormLabel>Destination Path</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/path/to/file"
className="font-mono"
/>
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
Enter the full path where the file should be uploaded in the
container (e.g., /app/config.json)
</p>
</FormItem>
)}
/>

<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Dropzone
{...field}
dropMessage="Drop file here or click to browse"
onChange={(files) => {
if (files && files.length > 0) {
field.onChange(files[0]);
} else {
field.onChange(null);
}
}}
/>
</FormControl>
<FormMessage />
{file instanceof File && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<span className="text-sm text-muted-foreground flex-1">
{file.name} ({(file.size / 1024).toFixed(2)} KB)
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange(null)}
>
Remove
</Button>
</div>
)}
</FormItem>
)}
/>

<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
isLoading={isLoading}
disabled={!file || isLoading}
>
Upload File
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
38 changes: 37 additions & 1 deletion apps/dokploy/server/api/routers/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
getContainersByAppNameMatch,
getServiceContainersByAppName,
getStackContainersByAppName,
uploadFileToContainer,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, uploadProcedure } from "../trpc";
import { uploadFileToContainerSchema } from "@/utils/schema";

export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;

Expand Down Expand Up @@ -143,4 +145,38 @@ export const dockerRouter = createTRPCRouter({
}
return await getServiceContainersByAppName(input.appName, input.serverId);
}),

uploadFileToContainer: protectedProcedure
.use(uploadProcedure)
.input(uploadFileToContainerSchema)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}

const file = input.file;
if (!(file instanceof File)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid file provided",
});
}

// Convert File to Buffer
const arrayBuffer = await file.arrayBuffer();
const fileBuffer = Buffer.from(arrayBuffer);

await uploadFileToContainer(
input.containerId,
fileBuffer,
file.name,
input.destinationPath,
input.serverId || null,
);

return { success: true, message: "File uploaded successfully" };
}),
});
12 changes: 12 additions & 0 deletions apps/dokploy/utils/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,15 @@ export const uploadFileSchema = zfd.formData({
});

export type UploadFile = z.infer<typeof uploadFileSchema>;

export const uploadFileToContainerSchema = zfd.formData({
containerId: z
.string()
.min(1)
.regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container ID"),
file: zfd.file(),
destinationPath: z.string().min(1),
serverId: z.string().optional(),
});

export type UploadFileToContainer = z.infer<typeof uploadFileToContainerSchema>;
85 changes: 85 additions & 0 deletions packages/server/src/services/docker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
execAsync,
execAsyncRemote,
Expand Down Expand Up @@ -472,3 +475,85 @@ export const getApplicationInfo = async (
return appArray;
} catch {}
};

export const uploadFileToContainer = async (
containerId: string,
fileBuffer: Buffer,
fileName: string,
destinationPath: string,
serverId?: string | null,
): Promise<void> => {
const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
if (!containerIdRegex.test(containerId)) {
throw new Error("Invalid container ID");
}

// Ensure destination path starts with /
const normalizedPath = destinationPath.startsWith("/")
? destinationPath
: `/${destinationPath}`;

if (serverId) {
// Remote server: transfer file via base64 encoding using heredoc
const base64Content = fileBuffer.toString("base64");
const tempFileName = `dokploy-upload-${Date.now()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
const tempPath = `/tmp/${tempFileName}`;

try {
// Create temp directory and write file on remote server using heredoc
// This handles large files and special characters better than echo
const writeCommand = `cat << 'EOF' | base64 -d > "${tempPath}"
${base64Content}
EOF
`;

await execAsyncRemote(serverId, writeCommand);

// Copy file into container
const copyCommand = `docker cp "${tempPath}" "${containerId}:${normalizedPath}"`;
await execAsyncRemote(serverId, copyCommand);

// Clean up temp file
const cleanupCommand = `rm -f "${tempPath}"`;
await execAsyncRemote(serverId, cleanupCommand);
} catch (error) {
// Try to clean up on error
try {
await execAsyncRemote(serverId, `rm -f "${tempPath}"`);
} catch {
// Ignore cleanup errors
}
throw new Error(
`Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
// Local server: use temp directory
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-upload-"));
const tempFilePath = join(tempDir, fileName);

try {
// Write file to temp directory
await writeFile(tempFilePath, fileBuffer);

// Copy file into container
const copyCommand = `docker cp "${tempFilePath}" "${containerId}:${normalizedPath}"`;
await execAsync(copyCommand);

// Clean up temp directory
await rm(tempFilePath, { force: true });
await rm(tempDir, { recursive: true, force: true });
} catch (error) {
// Try to clean up on error
try {
await rm(tempFilePath, { force: true });
await rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
throw new Error(
`Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
};