Object Storage and File Upload System Design 2026 — S3, Cloudflare R2, Presigned URLs, and Chunked Upload

Posted on: 4/20/2026 4:13:32 PM

Table of contents

  1. 1. What is Object Storage and Why Shouldn't Files Live on Application Servers?
  2. 2. Presigned URL — The Secret to Safe Direct Uploads
    1. 2.1. Presigned URL Upload Flow
      1. Why is a Confirm step needed?
    2. 2.2. Presigned URLs on Each Platform
      1. Presigned URL security notes
  3. 3. Multipart Upload — Handling Multi-GB Files
    1. 3.1. S3/R2 Multipart Upload Flow
      1. How to pick the optimal Part Size
    2. 3.2. Client-side Implementation in JavaScript
  4. 4. Tus Protocol — An Open Standard for Resumable Upload
    1. 4.1. HTTP Methods in the Tus Protocol
    2. 4.2. Important Tus Extensions
      1. Tus is being standardized by the IETF
  5. 5. S3 vs Cloudflare R2 vs Azure Blob Storage
    1. Which platform should you choose?
  6. 6. A Production-Ready File Upload System Architecture
    1. 6.1. The Temp Bucket vs Production Bucket Pattern
    2. 6.2. A Server-Side Upload Service in .NET 10
  7. 7. Post-Upload Processing Pipeline
    1. 7.1. Virus Scanning — Non-Negotiable
    2. 7.2. Content Validation — Don't Trust the Content-Type Header
  8. 8. CDN Integration and Delivery Optimization
    1. 8.1. Cloudflare R2 + Custom Domain
    2. 8.2. S3 + CloudFront
    3. 8.3. On-the-Fly Image Optimization
      1. Cache-Control headers for static assets
  9. 9. Security and Advanced Access Control
    1. 9.1. Signed URLs for Private Content
    2. 9.2. CORS Configuration
      1. Don't use AllowedOrigins: ["*"] in production
  10. 10. Lifecycle Policy and Cleanup Strategy
    1. 10.1. S3 Lifecycle Rules
    2. 10.2. A Background Job to Clean Up Orphaned Files
  11. 11. Performance Monitoring and Metrics
  12. 12. Best Practices Summary
    1. File Upload System Design Checklist
    2. References

Every web app needs file uploads — from a simple avatar to GB-sized videos. But once the system scales to millions of users, the question is no longer "upload to the server and store it somewhere" but how to design an upload pipeline that is fault-tolerant, resumable, secure, and cost-efficient. This article dives deep into Object Storage and File Upload system design for production 2026 — from Presigned URLs, Chunked Upload, the Tus Protocol, through a concrete comparison between AWS S3, Cloudflare R2, and Azure Blob Storage.

$0.00Cloudflare R2 egress fee
5TBMax size for a single S3 object
10,000Max parts in a Multipart Upload
~70%Server load reduction with Direct Upload

1. What is Object Storage and Why Shouldn't Files Live on Application Servers?

Object Storage is a storage model where data is managed as objects — each object consists of data (the file contents), metadata (descriptive information), and a unique key (identifier). Unlike traditional file systems (hierarchy of directories) or block storage (sectors/blocks), object storage provides a flat namespace with virtually unlimited scalability.

Why shouldn't you store files directly on your application servers?

  • Doesn't scale: When running multiple instances (horizontal scaling), a file on instance A doesn't exist on instance B. You'd have to rely on complex shared storage.
  • Disk is a bottleneck: An application server's disk I/O is finite. A 500MB file upload consumes bandwidth and CPU while other requests have to wait.
  • No integrated CDN: You'd need to configure a reverse proxy + CDN edge cache yourself — complex and error-prone.
  • Backup/recovery is painful: Object storage comes with versioning, cross-region replication, and lifecycle policies out of the box.
graph TB
    subgraph "❌ Anti-pattern: Server-side Storage"
        U1["Client"] -->|"Upload 500MB"| S1["App Server
CPU + Disk I/O"] S1 -->|"Save to disk"| D1["Local Disk
No replication"] S1 -->|"Serve file"| U1 end subgraph "✅ Best Practice: Object Storage" U2["Client"] -->|"1. Request Presigned URL"| S2["App Server
Handles logic only"] S2 -->|"2. Return URL"| U2 U2 -->|"3. Upload directly"| OS["Object Storage
S3 / R2 / Azure Blob"] OS -->|"4. CDN Serve"| CDN["Edge CDN
300+ PoPs"] CDN -->|"5. Fast delivery"| U2 end style U1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style S1 fill:#e94560,stroke:#fff,color:#fff style D1 fill:#ff9800,stroke:#fff,color:#fff style U2 fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50 style S2 fill:#4CAF50,stroke:#fff,color:#fff style OS fill:#2c3e50,stroke:#fff,color:#fff style CDN fill:#e94560,stroke:#fff,color:#fff

Figure 1: Anti-pattern vs Best Practice — Direct Upload to Object Storage

2. Presigned URL — The Secret to Safe Direct Uploads

A Presigned URL (also known as Signed URL / SAS Token) is a mechanism that lets clients upload or download files directly to/from object storage without going through the application server, and without needing any credentials on the client side. The server produces a URL with a digital signature (HMAC-SHA256), and the client uses that URL to PUT/GET the object within a limited time window.

2.1. Presigned URL Upload Flow

sequenceDiagram
    participant C as Client (Browser)
    participant A as API Server
    participant S as Object Storage (S3/R2)

    C->>A: POST /api/upload/init
{filename, contentType, size} Note over A: Validate: file type, size limit,
user quota, auth check A->>S: Generate Presigned PUT URL
(expires: 15 min, max-size) S-->>A: Presigned URL + headers A-->>C: {uploadUrl, objectKey, headers} C->>S: PUT uploadUrl
Content-Type: image/jpeg
Body: raw file bytes S-->>C: 200 OK + ETag C->>A: POST /api/upload/confirm
{objectKey, etag} Note over A: Verify ETag, update DB,
trigger post-processing A-->>C: {fileUrl, metadata}

Figure 2: Sequence diagram — Presigned URL Upload flow

Why is a Confirm step needed?

The client may receive a Presigned URL but never upload (URL expires) or upload a different file (wrong content type). The confirm step lets the server verify that the file actually exists in storage, the ETag matches, and the size is valid — only then is the record written to the database. Without this step, you'd end up with DB records pointing to non-existent objects.

2.2. Presigned URLs on Each Platform

AWS S3 — PutObject Presigned URL:

// .NET 10 — AWS SDK for .NET
using Amazon.S3;
using Amazon.S3.Model;

var s3Client = new AmazonS3Client();

var request = new GetPreSignedUrlRequest
{
    BucketName = "my-upload-bucket",
    Key = $"uploads/{userId}/{Guid.NewGuid()}/{fileName}",
    Verb = HttpVerb.PUT,
    Expires = DateTime.UtcNow.AddMinutes(15),
    ContentType = contentType,
};
// Limit upload size
request.Headers["x-amz-content-sha256"] = "UNSIGNED-PAYLOAD";

string presignedUrl = s3Client.GetPreSignedURL(request);

Cloudflare R2 — S3-compatible API:

// R2 uses the S3-compatible API — just swap the endpoint
var r2Client = new AmazonS3Client(
    new BasicAWSCredentials(r2AccessKey, r2SecretKey),
    new AmazonS3Config
    {
        ServiceURL = "https://<account-id>.r2.cloudflarestorage.com",
        ForcePathStyle = true
    }
);

var request = new GetPreSignedUrlRequest
{
    BucketName = "my-r2-bucket",
    Key = $"uploads/{objectKey}",
    Verb = HttpVerb.PUT,
    Expires = DateTime.UtcNow.AddMinutes(15),
    ContentType = contentType,
};

string presignedUrl = r2Client.GetPreSignedURL(request);

Azure Blob Storage — SAS Token:

// .NET 10 — Azure.Storage.Blobs SDK
using Azure.Storage.Blobs;
using Azure.Storage.Sas;

var blobClient = new BlobClient(connectionString, "uploads", objectKey);

var sasBuilder = new BlobSasBuilder
{
    BlobContainerName = "uploads",
    BlobName = objectKey,
    Resource = "b",
    ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15)
};
sasBuilder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);

Uri sasUri = blobClient.GenerateSasUri(sasBuilder);

Presigned URL security notes

Presigned URLs can leak through browser history, proxy logs, or Referer headers. Always use short expirations (5–15 minutes) and limit content-type and max size in the policy. For S3, use the x-amz-content-length-range condition in the POST policy to prevent oversized uploads.

3. Multipart Upload — Handling Multi-GB Files

When a file exceeds 100MB, a single PUT request becomes unreliable — network timeouts, connection resets, or browser memory limits can all cause the upload to fail, forcing a complete restart. Multipart Upload splits a file into multiple small parts (5MB – 5GB each), uploads them in parallel, and supports retrying individual parts.

3.1. S3/R2 Multipart Upload Flow

sequenceDiagram
    participant C as Client
    participant A as API Server
    participant S as S3/R2

    C->>A: POST /upload/multipart/init
{filename, size, contentType} A->>S: CreateMultipartUpload S-->>A: uploadId A-->>C: {uploadId, partSize, totalParts} loop For each Part (in parallel) C->>A: GET /upload/multipart/presign
{uploadId, partNumber} A->>S: Generate Presigned URL for part S-->>A: partPresignedUrl A-->>C: {partUrl} C->>S: PUT partUrl
Body: chunk bytes S-->>C: 200 + ETag end C->>A: POST /upload/multipart/complete
{uploadId, parts: [{partNumber, etag}]} A->>S: CompleteMultipartUpload
{parts} S-->>A: final ETag + location A-->>C: {fileUrl, metadata}

Figure 3: Multipart Upload flow — split the file into parts and upload in parallel

How to pick the optimal Part Size

  1. Minimum: 5MB (S3/R2 limit, except the last part)
  2. Recommended: 8-16MB for files < 1GB, 32-64MB for files > 1GB
  3. Maximum: 5GB per part, up to 10,000 parts
  4. Concurrency: 3-6 parts in parallel on good networks, 1-2 on weak networks
  5. Adaptive: Track upload speed and adjust concurrency automatically

3.2. Client-side Implementation in JavaScript

class ChunkedUploader {
  constructor(file, { partSize = 8 * 1024 * 1024, concurrency = 4 } = {}) {
    this.file = file;
    this.partSize = partSize;
    this.concurrency = concurrency;
    this.totalParts = Math.ceil(file.size / partSize);
    this.completedParts = [];
    this.abortController = new AbortController();
  }

  async upload(apiBase) {
    // 1. Init multipart upload
    const { uploadId } = await fetch(`${apiBase}/upload/multipart/init`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        filename: this.file.name,
        contentType: this.file.type,
        size: this.file.size,
      }),
    }).then(r => r.json());

    // 2. Upload parts with a concurrency limit
    const partNumbers = Array.from({ length: this.totalParts }, (_, i) => i + 1);
    await this.#uploadPartsWithConcurrency(apiBase, uploadId, partNumbers);

    // 3. Complete
    const result = await fetch(`${apiBase}/upload/multipart/complete`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ uploadId, parts: this.completedParts }),
    }).then(r => r.json());

    return result;
  }

  async #uploadPartsWithConcurrency(apiBase, uploadId, partNumbers) {
    const executing = new Set();
    for (const partNumber of partNumbers) {
      const promise = this.#uploadPart(apiBase, uploadId, partNumber)
        .then(() => executing.delete(promise));
      executing.add(promise);
      if (executing.size >= this.concurrency) {
        await Promise.race(executing);
      }
    }
    await Promise.all(executing);
  }

  async #uploadPart(apiBase, uploadId, partNumber, retries = 3) {
    const start = (partNumber - 1) * this.partSize;
    const end = Math.min(start + this.partSize, this.file.size);
    const chunk = this.file.slice(start, end);

    // Get the presigned URL for this part
    const { partUrl } = await fetch(
      `${apiBase}/upload/multipart/presign?uploadId=${uploadId}&partNumber=${partNumber}`
    ).then(r => r.json());

    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        const response = await fetch(partUrl, {
          method: 'PUT',
          body: chunk,
          signal: this.abortController.signal,
        });
        const etag = response.headers.get('ETag');
        this.completedParts.push({ partNumber, etag });
        return;
      } catch (err) {
        if (attempt === retries - 1) throw err;
        await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
      }
    }
  }

  abort() {
    this.abortController.abort();
  }
}

4. Tus Protocol — An Open Standard for Resumable Upload

Multipart upload solves the large file problem, but there's still a limitation: if the browser is closed midway or the user switches networks, the client must know precisely which parts uploaded successfully in order to resume — this logic is fairly complex. The Tus Protocol (short for "transloadit upload server") is an open protocol built on HTTP that offers a standardized resumable upload mechanism with a simple API.

graph LR
    subgraph "Tus Protocol Flow"
        A["POST /files
Upload-Length: 1GB"] -->|"201 Created
Location: /files/abc"| B["PATCH /files/abc
Upload-Offset: 0
Body: chunk 1"] B -->|"204 No Content
Upload-Offset: 50MB"| C["❌ Connection lost"] C -->|"HEAD /files/abc"| D["Server returns
Upload-Offset: 50MB"] D -->|"PATCH /files/abc
Upload-Offset: 50MB
Body: chunk 2"| E["✅ Upload complete"] end style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style B fill:#4CAF50,stroke:#fff,color:#fff style C fill:#e94560,stroke:#fff,color:#fff style D fill:#ff9800,stroke:#fff,color:#fff style E fill:#4CAF50,stroke:#fff,color:#fff

Figure 4: Tus Protocol — resume upload from the last successfully uploaded offset

4.1. HTTP Methods in the Tus Protocol

Method Endpoint Purpose Important headers
POST /files Create a new upload Upload-Length, Upload-Metadata, Tus-Resumable
HEAD /files/{id} Check current offset (resume) Tus-Resumable
PATCH /files/{id} Upload data from offset Upload-Offset, Content-Type: application/offset+octet-stream
DELETE /files/{id} Cancel the upload (Extension: Termination) Tus-Resumable
OPTIONS /files Discover server capabilities

4.2. Important Tus Extensions

  • Creation: Lets you create a new upload resource via POST. Practically mandatory for any implementation.
  • Concatenation: Combines multiple parallel uploads into a single file, enabling multipart-style parallel upload.
  • Checksum: Verifies data integrity for each PATCH request using CRC32, MD5, or SHA1.
  • Expiration: Server communicates when an unfinished upload will be discarded — gives the client a clear deadline.
  • Creation With Upload: Combines POST + first PATCH in a single request, reducing latency for small files.

Tus is being standardized by the IETF

The IETF draft "Resumable Uploads for HTTP" is standardizing parts of the Tus Protocol into an official RFC. Cloudflare Stream, Supabase Storage, Vimeo, and many other large platforms already support Tus. The Uppy library (JavaScript) and tusd (Go server) are the most popular reference implementations.

5. S3 vs Cloudflare R2 vs Azure Blob Storage

Criterion AWS S3 Cloudflare R2 Azure Blob Storage
Storage price $0.023/GB/month (Standard) $0.015/GB/month $0.018/GB/month (Hot)
Egress fee $0.09/GB (after 100GB free) $0 — Completely free $0.087/GB
Free tier 5GB storage, 20K GET, 2K PUT/month (12 months) 10GB storage, 10M GET, 1M PUT/month (forever) 5GB LRS Hot, 20K GET, 10K Write/month (12 months)
Multipart upload Yes — max 10,000 parts, 5MB–5GB/part Yes — S3-compatible API Yes — Block Blob (max 50,000 blocks, 4GB/block)
Presigned URL Yes — max 7 days expiry Yes — S3-compatible SAS Token — extremely granular
Built-in CDN CloudFront (separate cost) Built-in 300+ PoPs Azure CDN (separate cost)
S3-compatible API Native Yes No (proprietary API)
Object Lock (WORM) Yes Not yet Yes (Immutable Blob Storage)
Versioning Yes Not yet (2026) Yes
Event notifications S3 Event → Lambda/SQS/SNS R2 Event → Workers Event Grid → Azure Functions
Ecosystem Broadest — Lambda, Athena, EMR Workers, D1, KV, AI Gateway Functions, Cognitive Services, Synapse

Which platform should you choose?

Cloudflare R2 is the best choice for egress-heavy workloads — media delivery, CDN origin, public assets. It saves 98–99% on bandwidth costs compared to S3. AWS S3 is a good fit when you're already in the AWS ecosystem and need advanced features (Object Lock, S3 Select, Glacier). Azure Blob is similar for the Azure ecosystem — deep integration with Azure Functions and Cognitive Services.

6. A Production-Ready File Upload System Architecture

A production-ready file upload system needs to solve more than "PUT a file to storage": validation, virus scanning, image processing, metadata indexing, access control, and cleanup of orphaned files.

graph TB
    subgraph "Client Layer"
        CL["Web/Mobile Client
Uppy + Tus/Multipart"] end subgraph "API Layer" AG["API Gateway
Rate Limiting + Auth"] US["Upload Service
.NET 10 Minimal API"] end subgraph "Storage Layer" TB["Temp Bucket
(raw uploads)"] PB["Production Bucket
(processed files)"] end subgraph "Processing Pipeline" EV["Event Notification
S3 Event / R2 Workers"] VS["Virus Scanner
ClamAV / Defender"] IP["Image Processor
Resize, WebP, AVIF"] MI["Metadata Indexer
DB + Search Index"] end subgraph "Delivery Layer" CDN["CDN Edge
CloudFront / Cloudflare"] TF["Transform on-the-fly
Image Resize URL"] end CL -->|"1. Init Upload"| AG AG --> US US -->|"2. Presigned URL"| CL CL -->|"3. Direct Upload"| TB TB -->|"4. Event Trigger"| EV EV --> VS VS -->|"Clean"| IP IP -->|"Processed"| PB IP --> MI VS -->|"Infected → Delete"| TB PB --> CDN CDN --> TF TF -->|"Optimized delivery"| CL style CL fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50 style AG fill:#2c3e50,stroke:#fff,color:#fff style US fill:#2c3e50,stroke:#fff,color:#fff style TB fill:#ff9800,stroke:#fff,color:#fff style PB fill:#4CAF50,stroke:#fff,color:#fff style EV fill:#e94560,stroke:#fff,color:#fff style VS fill:#e94560,stroke:#fff,color:#fff style IP fill:#e94560,stroke:#fff,color:#fff style MI fill:#e94560,stroke:#fff,color:#fff style CDN fill:#4CAF50,stroke:#fff,color:#fff style TF fill:#4CAF50,stroke:#fff,color:#fff

Figure 5: Complete File Upload System architecture for production

6.1. The Temp Bucket vs Production Bucket Pattern

This is the single most important pattern in file upload system design. Instead of uploading directly to the production bucket, the client uploads to a temp bucket first. Only after virus scanning + processing is the file moved to the production bucket. Benefits:

  • Safe isolation: Unscanned files are never served via the CDN
  • Easy cleanup: A lifecycle policy auto-deletes files in the temp bucket after 24h — orphaned uploads handled automatically
  • Permission separation: Temp bucket is write-only (presigned PUT); production bucket is read-only for the CDN

6.2. A Server-Side Upload Service in .NET 10

// .NET 10 Minimal API — Upload Service
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IAmazonS3>(sp =>
{
    // Supports both S3 and R2 via config
    var config = sp.GetRequiredService<IConfiguration>();
    var s3Config = new AmazonS3Config
    {
        ServiceURL = config["Storage:Endpoint"],
        ForcePathStyle = true
    };
    return new AmazonS3Client(
        config["Storage:AccessKey"],
        config["Storage:SecretKey"],
        s3Config
    );
});

var app = builder.Build();

// Init multipart upload
app.MapPost("/api/upload/init", async (
    InitUploadRequest request,
    IAmazonS3 s3,
    HttpContext ctx) =>
{
    // Validate
    var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp",
                               "application/pdf", "video/mp4" };
    if (!allowedTypes.Contains(request.ContentType))
        return Results.BadRequest("File type not allowed");

    if (request.Size > 5L * 1024 * 1024 * 1024) // 5GB max
        return Results.BadRequest("File too large");

    var objectKey = $"uploads/{ctx.User.FindFirst("sub")?.Value}" +
                    $"/{DateTime.UtcNow:yyyy/MM/dd}" +
                    $"/{Guid.NewGuid()}{Path.GetExtension(request.FileName)}";

    if (request.Size > 100 * 1024 * 1024) // > 100MB → multipart
    {
        var initResponse = await s3.InitiateMultipartUploadAsync(
            new InitiateMultipartUploadRequest
            {
                BucketName = "temp-uploads",
                Key = objectKey,
                ContentType = request.ContentType,
            });

        var partSize = request.Size > 1024 * 1024 * 1024
            ? 64 * 1024 * 1024   // 64MB for files > 1GB
            : 8 * 1024 * 1024;   // 8MB for files < 1GB

        return Results.Ok(new
        {
            Mode = "multipart",
            UploadId = initResponse.UploadId,
            ObjectKey = objectKey,
            PartSize = partSize,
            TotalParts = (int)Math.Ceiling((double)request.Size / partSize),
        });
    }

    // Small file → single presigned URL
    var presignedUrl = s3.GetPreSignedURL(new GetPreSignedUrlRequest
    {
        BucketName = "temp-uploads",
        Key = objectKey,
        Verb = HttpVerb.PUT,
        Expires = DateTime.UtcNow.AddMinutes(15),
        ContentType = request.ContentType,
    });

    return Results.Ok(new
    {
        Mode = "single",
        UploadUrl = presignedUrl,
        ObjectKey = objectKey,
    });
});

// Presign each part for multipart uploads
app.MapGet("/api/upload/presign-part", async (
    string uploadId, string objectKey, int partNumber,
    IAmazonS3 s3) =>
{
    var presignedUrl = s3.GetPreSignedURL(new GetPreSignedUrlRequest
    {
        BucketName = "temp-uploads",
        Key = objectKey,
        Verb = HttpVerb.PUT,
        Expires = DateTime.UtcNow.AddMinutes(30),
    });
    // Append uploadId and partNumber to the URL query
    var uri = new UriBuilder(presignedUrl);
    uri.Query += $"&uploadId={uploadId}&partNumber={partNumber}";

    return Results.Ok(new { PartUrl = uri.ToString() });
});

// Complete multipart
app.MapPost("/api/upload/complete", async (
    CompleteUploadRequest request,
    IAmazonS3 s3,
    AppDbContext db) =>
{
    if (request.Mode == "multipart")
    {
        await s3.CompleteMultipartUploadAsync(
            new CompleteMultipartUploadRequest
            {
                BucketName = "temp-uploads",
                Key = request.ObjectKey,
                UploadId = request.UploadId,
                PartETags = request.Parts
                    .Select(p => new PartETag(p.PartNumber, p.ETag))
                    .ToList(),
            });
    }

    // Write metadata to DB — the processing pipeline will handle the file
    var fileRecord = new FileUpload
    {
        ObjectKey = request.ObjectKey,
        Status = FileStatus.Pending,
        UploadedAt = DateTime.UtcNow,
    };
    db.FileUploads.Add(fileRecord);
    await db.SaveChangesAsync();

    return Results.Ok(new { FileId = fileRecord.Id, Status = "processing" });
});

app.Run();

7. Post-Upload Processing Pipeline

A successful file upload is only the first step. A typical post-upload processing pipeline includes:

graph LR
    A["File uploaded
→ Temp Bucket"] --> B["Virus Scan
ClamAV"] B -->|"Clean"| C["Content Validation
Magic bytes check"] B -->|"Infected"| X["🗑️ Delete +
Alert"] C -->|"Valid"| D["Image Processing
Resize + WebP/AVIF"] C -->|"Invalid"| X D --> E["Move to
Production Bucket"] E --> F["Update DB
Status: Active"] F --> G["Invalidate CDN
cache if needed"] style A fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50 style B fill:#ff9800,stroke:#fff,color:#fff style C fill:#ff9800,stroke:#fff,color:#fff style D fill:#2c3e50,stroke:#fff,color:#fff style E fill:#4CAF50,stroke:#fff,color:#fff style F fill:#4CAF50,stroke:#fff,color:#fff style G fill:#4CAF50,stroke:#fff,color:#fff style X fill:#e94560,stroke:#fff,color:#fff

Figure 6: Post-upload processing pipeline

7.1. Virus Scanning — Non-Negotiable

Any file a user uploads may contain malware. Common virus scanning solutions for object storage:

Solution Type Integration Cost
ClamAV Open-source S3 Event → Lambda → ClamAV container Compute cost only
Amazon GuardDuty Malware Protection Managed Native S3 integration $0.60/GB scanned
Microsoft Defender for Storage Managed Native Azure Blob integration $0.15/10K transactions
Cloudflare Workers + ClamAV API Hybrid R2 Event Notification → Worker Workers pricing + ClamAV hosting

7.2. Content Validation — Don't Trust the Content-Type Header

A client can send Content-Type: image/jpeg while the file is actually a .exe. Always check the magic bytes (file signature) of the actual file:

public static class FileSignatureValidator
{
    private static readonly Dictionary<string, byte[][]> FileSignatures = new()
    {
        ["image/jpeg"] = [new byte[] { 0xFF, 0xD8, 0xFF }],
        ["image/png"] = [new byte[] { 0x89, 0x50, 0x4E, 0x47 }],
        ["application/pdf"] = [new byte[] { 0x25, 0x50, 0x44, 0x46 }],
        ["video/mp4"] = [
            new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70 },
            new byte[] { 0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70 },
            new byte[] { 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70 },
        ],
    };

    public static bool IsValid(Stream fileStream, string declaredContentType)
    {
        if (!FileSignatures.TryGetValue(declaredContentType, out var signatures))
            return false;

        var headerBytes = new byte[8];
        fileStream.Read(headerBytes, 0, 8);
        fileStream.Position = 0;

        return signatures.Any(sig =>
            headerBytes.Take(sig.Length).SequenceEqual(sig));
    }
}

8. CDN Integration and Delivery Optimization

After a file has been processed and lives in the production bucket, the final step is to serve it efficiently via a CDN. Each platform has its own approach:

8.1. Cloudflare R2 + Custom Domain

R2 is integrated with the Cloudflare CDN out of the box — just connect a custom domain and the file is cached at 300+ edge locations, with no egress fees. This is R2's biggest competitive advantage.

# Configure an R2 public bucket with a custom domain
# In Cloudflare Dashboard: R2 → Bucket Settings → Public Access
# Or via the Wrangler CLI:
npx wrangler r2 bucket create production-assets
npx wrangler r2 bucket domain add production-assets --domain assets.example.com

8.2. S3 + CloudFront

# CloudFront distribution for an S3 bucket
# Origin Access Control (OAC) — replacement for Origin Access Identity (OAI)
aws cloudfront create-distribution \
  --origin-domain-name my-bucket.s3.amazonaws.com \
  --default-root-object index.html \
  --cache-policy-id "658327ea-f89d-4fab-a63d-7e88639e58f6" \
  --origin-access-control-id "E2..."

8.3. On-the-Fly Image Optimization

Instead of pre-generating many image sizes, use transform URLs to resize + convert format at the edge:

Service URL pattern Cost
Cloudflare Images /cdn-cgi/image/width=400,format=auto/path $0.50/1000 unique transforms
AWS Lambda@Edge Custom (via CloudFront Function) Lambda pricing + CF pricing
Azure CDN Rules Engine Custom (via Azure Functions) Functions pricing + CDN pricing
imgproxy (self-hosted) /resize:fit:400/plain/s3://bucket/path Hosting cost only (open-source)

Cache-Control headers for static assets

When the object key contains a content hash (e.g., avatar-a1b2c3d4.webp), set Cache-Control: public, max-age=31536000, immutable — cache forever at the CDN edge, no revalidation requests. When the object key is mutable (e.g., profile/123/avatar.jpg), use Cache-Control: public, max-age=3600, s-maxage=86400 combined with CDN cache invalidation whenever the user updates the file.

9. Security and Advanced Access Control

9.1. Signed URLs for Private Content

Not every file should be public. For private content (internal documents, invoices, reports), use signed download URLs with short expirations:

// Generate a signed download URL — only when the user has permission
app.MapGet("/api/files/{fileId}/download", async (
    int fileId,
    IAmazonS3 s3,
    AppDbContext db,
    HttpContext ctx) =>
{
    var file = await db.FileUploads.FindAsync(fileId);
    if (file is null) return Results.NotFound();

    // Check access permissions
    var userId = ctx.User.FindFirst("sub")?.Value;
    if (file.OwnerId != userId && !ctx.User.IsInRole("Admin"))
        return Results.Forbid();

    var presignedUrl = s3.GetPreSignedURL(new GetPreSignedUrlRequest
    {
        BucketName = "production-assets",
        Key = file.ObjectKey,
        Verb = HttpVerb.GET,
        Expires = DateTime.UtcNow.AddMinutes(5),
        ResponseHeaderOverrides = new ResponseHeaderOverrides
        {
            ContentDisposition = $"attachment; filename=\"{file.OriginalName}\"",
        },
    });

    return Results.Redirect(presignedUrl);
});

9.2. CORS Configuration

To let the browser upload directly to S3/R2, the bucket must be configured with the correct CORS rules:

{
    "CORSRules": [
        {
            "AllowedOrigins": ["https://example.com"],
            "AllowedMethods": ["PUT", "POST"],
            "AllowedHeaders": ["Content-Type", "x-amz-*"],
            "ExposeHeaders": ["ETag", "x-amz-request-id"],
            "MaxAgeSeconds": 3600
        }
    ]
}

Don't use AllowedOrigins: ["*"] in production

Wildcard CORS allows any domain to upload files to your bucket via leaked presigned URLs. Always whitelist the exact domain of your application. If you have several domains (staging, production), list them individually.

10. Lifecycle Policy and Cleanup Strategy

One of the biggest hidden costs of object storage is orphaned files — files that were uploaded but never confirmed, incomplete multipart uploads, or files deleted in the app but never removed from storage.

10.1. S3 Lifecycle Rules

{
    "Rules": [
        {
            "ID": "cleanup-temp-uploads",
            "Filter": { "Prefix": "uploads/" },
            "Status": "Enabled",
            "Expiration": { "Days": 1 },
            "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
        },
        {
            "ID": "archive-old-files",
            "Filter": { "Prefix": "production/" },
            "Status": "Enabled",
            "Transitions": [
                { "Days": 90, "StorageClass": "STANDARD_IA" },
                { "Days": 365, "StorageClass": "GLACIER_IR" }
            ]
        }
    ]
}

10.2. A Background Job to Clean Up Orphaned Files

// Daily background job — removes files with no DB record
public class OrphanedFileCleanupJob(
    IAmazonS3 s3,
    AppDbContext db,
    ILogger<OrphanedFileCleanupJob> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
        while (await timer.WaitForNextTickAsync(ct))
        {
            var request = new ListObjectsV2Request
            {
                BucketName = "production-assets",
                Prefix = "production/",
            };

            ListObjectsV2Response response;
            do
            {
                response = await s3.ListObjectsV2Async(request, ct);
                var keys = response.S3Objects.Select(o => o.Key).ToList();

                var existingKeys = await db.FileUploads
                    .Where(f => keys.Contains(f.ObjectKey))
                    .Select(f => f.ObjectKey)
                    .ToListAsync(ct);

                var orphaned = keys.Except(existingKeys).ToList();
                foreach (var key in orphaned)
                {
                    await s3.DeleteObjectAsync("production-assets", key, ct);
                    logger.LogInformation("Deleted orphaned file: {Key}", key);
                }

                request.ContinuationToken = response.NextContinuationToken;
            } while (response.IsTruncated);
        }
    }
}

11. Performance Monitoring and Metrics

P95 < 3sUpload latency for files < 10MB
99.9%Target upload success rate
< 5minProcessing pipeline SLA
< 50msCDN cache hit latency

Important metrics to monitor:

  • Upload success rate: Completed uploads / initiated uploads. If < 95%, investigate network timeouts, too-short presigned URL expiration, or CORS misconfiguration.
  • Upload latency P50/P95/P99: Track by file size bucket. Unusually high latency often signals a region mismatch between the user and bucket.
  • Processing pipeline duration: Time from upload completion → file ready to serve. The bottleneck is usually virus scanning.
  • Storage cost trend: Watch total storage size + egress bandwidth. Alert when cost grows > 20% over the previous month.
  • Orphaned file count: Number of objects in storage without a DB record. A cleanup job should keep this near zero.

12. Best Practices Summary

File Upload System Design Checklist

  1. Always use Direct Upload (Presigned URLs) — don't proxy files through the application server
  2. Use Multipart for files > 100MB — split into 8-64MB parts, upload in parallel
  3. Temp → Production bucket pattern — isolate unscanned/unprocessed files
  4. Mandatory virus scanning — ClamAV (free) or a managed service
  5. Validate magic bytes — never trust the Content-Type header from the client
  6. Lifecycle policies — auto-delete temp files, archive old files, abort incomplete multipart uploads
  7. CORS whitelist — don't use wildcard origins in production
  8. CDN with immutable caching — content hash in object key, Cache-Control: immutable
  9. Monitor upload success rate — alert when < 95%
  10. Consider Tus Protocol — when you need cross-session resumable uploads (mobile apps, very large files)

Designing a File Upload System isn't just "upload a file to the cloud" — it's a combined problem of system design (scalability, reliability), security (virus scanning, access control), cost optimization (egress fees, storage tiering), and user experience (resumable, progress tracking). With the Presigned URL + Temp Bucket + Processing Pipeline + CDN Delivery architecture laid out in this article, you have a solid foundation for building a file-handling system that scales from startup to enterprise.

References