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:

  1. UI Selection – Users interact with React components (attachment-upload.tsx) to select files.
  2. Metadata Generation – Utility functions create unique filenames and extract MIME types and sizes.
  3. Signed-URL Negotiation – The client requests a pre-signed upload URL from the Django API (/api/assets/v2/.../attachments/).
  4. Direct Upload – The browser streams the file directly to the external storage provider using the signed URL.
  5. Status Confirmation – Once uploaded, the client notifies the API to mark the attachment as complete.
  6. Lifecycle Management – Attachments are retrieved via GET requests and deleted via DELETE endpoints.

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

Utility Functions

UI Components

Backend Infrastructure

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 IssueAttachmentService on 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →