How Plane Handles File Attachments and Storage: A Complete Architecture Guide
Plane implements a signed-URL upload pattern that streams files directly from the client to external object storage (like AWS S3) after obtaining a pre-signed URL from the Django backend, separating binary payload handling from metadata management.
Plane is an open-source project management platform that manages file attachments through a distributed architecture designed to minimize server load. The system keeps attachment metadata—names, sizes, and external storage identifiers—in PostgreSQL via Django models while delegating actual binary storage to scalable object stores. Understanding this architecture helps developers extend Plane's file handling capabilities or troubleshoot upload failures.
Architecture Overview
Plane's attachment flow follows a six-stage client-server pipeline that eliminates server-side bottlenecks:
- UI Selection – Users interact with React components (
attachment-upload.tsx) to select files. - Metadata Generation – Utility functions create unique filenames and extract MIME types and sizes.
- Signed-URL Negotiation – The client requests a pre-signed upload URL from the Django API (
/api/assets/v2/.../attachments/). - Direct Upload – The browser streams the file directly to the external storage provider using the signed URL.
- Status Confirmation – Once uploaded, the client notifies the API to mark the attachment as complete.
- Lifecycle Management – Attachments are retrieved via
GETrequests and deleted viaDELETEendpoints.
Client-Side Implementation
Metadata Preparation
Before any network request, Plane normalizes file metadata using utilities in packages/utils/src/attachment.ts. The generateFileName function creates timestamped unique identifiers, while getFileMetaDataForUpload extracts the size and MIME type into a standardized payload.
import { generateFileName, getFileMetaDataForUpload } from '@plane/utils';
const file = document.getElementById('file-input').files[0];
const metadata = {
name: generateFileName(file.name),
...getFileMetaDataForUpload(file)
};
// Returns: { name: "20240115-abc123.jpg", size: 1024000, mime_type: "image/jpeg" }
Upload Service Layer
The IssueAttachmentService class in apps/web/core/services/issue/issue_attachment.service.ts orchestrates the three-phase upload process. It first posts metadata to obtain a signed URL, then delegates the binary transfer to FileUploadService, and finally patches the attachment status.
The key method uploadIssueAttachment handles the complete lifecycle:
import { IssueAttachmentService } from '@/services/issue/issue_attachment.service';
const attachmentService = new IssueAttachmentService();
async function handleUpload(file: File, workspaceSlug: string, projectId: string, issueId: string) {
try {
const result = await attachmentService.uploadIssueAttachment(
workspaceSlug,
projectId,
issueId,
file,
(progressEvent) => {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
console.log(`Progress: ${percent}%`);
}
);
return result;
} catch (error) {
console.error('Upload failed:', error);
}
}
Backend API and Database Schema
Django Models and Migrations
Attachment metadata resides in the IssueAttachment model, which was extended via migration 0072_issueattachment_external_id_and_more.py in apps/api/plane/db/migrations/. This migration adds fields to link Plane records to external storage identifiers, storing the S3 key or equivalent without hosting the binary data in PostgreSQL.
The model maintains referential integrity to issues while keeping the actual payload in object storage, ensuring the database remains lightweight even with thousands of attachments.
Signed URL Generation
The Django view at apps/api/plane/app/views/issue/attachment.py exposes the REST endpoints under /api/assets/v2/. When receiving a POST request with file metadata, the backend generates a pre-signed URL valid for a limited duration (typically S3 pre-signed URLs) and returns it along with the attachment record ID.
Key endpoints in this flow:
POST /api/assets/v2/workspaces/{slug}/projects/{id}/issues/{id}/attachments/– Creates the attachment record and returns the signed URL.PATCH /api/assets/v2/workspaces/{slug}/projects/{id}/issues/{id}/attachments/{id}/– Updates the upload status after successful client-side transfer.GET /api/assets/v2/workspaces/{slug}/projects/{id}/issues/{id}/attachments/– Lists all attachments for an issue.DELETE /api/assets/v2/workspaces/{slug}/projects/{id}/issues/{id}/attachments/{asset_id}/– Removes the attachment reference.
Step-by-Step Upload Flow
Phase 1: Metadata Submission
The client sends { name, size, mime_type } to the backend. The Django API creates an IssueAttachment record in a "pending" state and generates a signed URL for the specific external storage path.
Phase 2: Binary Streaming
Using the signedURLResponse.upload_data.url returned by the API, FileUploadService.uploadFile streams the file bytes directly to the object store. This bypasses Plane's servers entirely, enabling large file support without consuming application bandwidth.
Phase 3: Status Verification
After the storage provider confirms the upload, the client calls updateIssueAttachmentUploadStatus via PATCH request. This marks the attachment as "uploaded" in the database, making it visible in the attachments-list.tsx UI component for other users.
Key Implementation Files
Core Service Layer
apps/web/core/services/issue/issue_attachment.service.ts– ImplementsuploadIssueAttachment,getIssueAttachments, anddeleteIssueAttachmentmethods (lines 50-94).apps/web/services/api.service.ts– BaseAPIServiceclass providing authenticated HTTP client functionality.
Utility Functions
packages/utils/src/attachment.ts– ContainsgenerateFileNameandgetFileMetaDataForUploadfor filename sanitization and metadata extraction.
UI Components
apps/web/core/components/issues/attachment/attachment-upload.tsx– Drag-and-drop interface triggering the upload flow.apps/web/core/components/issues/attachment/attachments-list.tsx– Renders uploaded attachments with delete actions.
Backend Infrastructure
apps/api/plane/app/views/issue/attachment.py– Django REST API view handling signed URL generation.apps/api/plane/db/migrations/0072_issueattachment_external_id_and_more.py– Database schema linking attachments to external storage.
Summary
- Plane uses a signed-URL pattern that separates metadata management from binary storage, keeping the Django API lightweight.
- The IssueAttachment model in PostgreSQL stores only references to external storage, while actual files reside in object stores like AWS S3.
- Direct client-to-storage uploads eliminate server bottlenecks and support progress tracking via XMLHttpRequest events.
- The three-phase upload process (metadata request → direct upload → status confirmation) ensures data integrity while maximizing scalability.
- All attachment operations route through
IssueAttachmentServiceon the client and the/api/assets/v2/endpoints on the backend.
Frequently Asked Questions
How does Plane handle large file uploads without server timeouts?
Plane delegates the binary transfer to external object storage via pre-signed URLs. The client uploads directly to S3 (or equivalent), so the Django API only handles small JSON metadata requests, preventing server-side timeouts regardless of file size.
Where is the actual file data stored in Plane's architecture?
The binary data lives in external object storage (typically AWS S3), while Plane's PostgreSQL database stores only metadata in the IssueAttachment model—including the filename, size, MIME type, and external storage identifier—enabling efficient querying without bloating the database.
What happens if a user cancels an upload midway through?
Since uploads stream directly to object storage, cancelling the HTTP request to the signed URL simply leaves an orphaned object that may be cleaned up by storage-level lifecycle policies. The attachment record in Plane remains in a "pending" state until updateIssueAttachmentUploadStatus is called, and can be garbage collected by background jobs if left incomplete.
Can I extend Plane's attachment system to support other storage providers?
Yes. The architecture abstracts storage specifics into the signed URL generation logic in apps/api/plane/app/views/issue/attachment.py. Implementing a different provider requires modifying the backend to generate pre-signed URLs for your specific object store (Azure Blob, Google Cloud Storage, etc.) while keeping the client-side IssueAttachmentService interface unchanged.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →