Skip to content

All lessons

Area 08 // BackendLesson 137 / 14014 min read

File uploads to S3

AI apps receive documents, images and audio from users, and the robust way to handle them is to validate the request, let the browser upload straight to object storage such as Amazon S3 using a short-lived presigned URL, and then process the file in a background job triggered by the upload.

Think of it like this

Analogy

A storage company doesn't carry your boxes through its office. The front desk checks your booking and gives you a one-time code for a specific locker that works for the next five minutes (a presigned URL). You drive to the warehouse and put the box in yourself. A sensor tells the staff a box has arrived, and they come to check and catalogue it (the background job).

The problem it solves

Users upload PDFs for RAG, images for multimodal models, and audio for transcription. Sending 200 MB through your API servers ties them up, hits request size and timeout limits, and makes uploads fail on slow connections. Storing files on server disks loses them when containers restart. And uploads are a security risk: huge files, wrong types, malware, path tricks in filenames, and buckets accidentally left public.

How it works, step by step

  1. Use object storage: Amazon S3 or a compatible store (Google Cloud Storage, Azure Blob, Cloudflare R2, MinIO). Files are objects in a bucket, addressed by a key like uploads/{tenant}/{user}/{uuid}.pdf.
  2. The client asks your API to start an upload with the filename, content type and size.
  3. The API validates: authenticated user, allowed types, size limit, quota; it generates a safe key (never trust the filename for paths).
  4. The API returns a presigned URL (expires in minutes, limited to that key and content type) and an upload ID; the browser PUTs the file directly to storage (see the code). Large files use multipart uploads (parts in parallel, resumable).
  5. Upload completes → event: the storage service emits an event (or the client calls /uploads/{id}/complete), which enqueues an ingestion job (queues and background jobs).
  6. The worker processes it: verify actual file type and size, scan for malware, then parse, chunk and embed (document parsing), recording status.
  7. Security: private buckets (block public access), encryption at rest, least-privilege credentials for the API and workers (least privilege), keys scoped by tenant, lifecycle rules to delete temporary files, and presigned download URLs when users need their files back.
  8. Keep originals and derived data separate: the original file in storage, extracted text and chunks in your database or index, linked by document ID (RAG access control).
The API hands out a short-lived upload URL; the file goes straight to storage; a worker processes it.

See it in code

Install
pip install boto3 moto
Needs
Python 3.10+. moto fakes S3 locally, so no AWS account or network is needed.

Validating an upload request, creating a presigned URL, and storing an object, all against a local fake of S3.

s3_uploads.py
import os
import boto3
from moto import mock_aws             # fakes AWS locally; remove it (and use real credentials) in production
 
os.environ.update(AWS_ACCESS_KEY_ID="testing", AWS_SECRET_ACCESS_KEY="testing", AWS_DEFAULT_REGION="eu-west-2")
ALLOWED = {"application/pdf", "text/markdown", "text/plain"}
MAX_BYTES = 20 * 1024 * 1024
 
@mock_aws
def demo():
    s3 = boto3.client("s3")
    s3.create_bucket(Bucket="rag-uploads", CreateBucketConfiguration={"LocationConstraint": "eu-west-2"})
 
    # 1. The API checks the request, then hands the browser a short-lived PRESIGNED URL to upload directly.
    def start_upload(user: str, filename: str, content_type: str, size: int):
        if content_type not in ALLOWED or size > MAX_BYTES:
            return {"error": "file type or size not allowed"}
        key = f"uploads/{user}/{filename}"                       # never trust the filename for paths in real code
        url = s3.generate_presigned_url("put_object", ExpiresIn=300,
                                        Params={"Bucket": "rag-uploads", "Key": key, "ContentType": content_type})
        return {"key": key, "upload_url": url.split("?")[0] + "?<signature>"}
 
    print(start_upload("alice", "policy.pdf", "application/pdf", 1_200_000))
    print(start_upload("alice", "tool.exe", "application/x-msdownload", 50_000))
 
    # 2. The browser PUTs the file to that URL (simulated here); 3. an event triggers ingestion.
    s3.put_object(Bucket="rag-uploads", Key="uploads/alice/policy.pdf", Body=b"%PDF-1.7 ...",
                  ContentType="application/pdf")
    for obj in s3.list_objects_v2(Bucket="rag-uploads", Prefix="uploads/alice/")["Contents"]:
        head = s3.head_object(Bucket="rag-uploads", Key=obj["Key"])
        print("stored:", obj["Key"], obj["Size"], "bytes,", head["ContentType"])
 
demo()
Output
{'key': 'uploads/alice/policy.pdf', 'upload_url': 'https://rag-uploads.s3.amazonaws.com/uploads/alice/policy.pdf?<signature>'}
{'error': 'file type or size not allowed'}
stored: uploads/alice/policy.pdf 12 bytes, application/pdf

The real URL's query string carries the signature and expiry; it's hidden here because signed URLs should be treated like short-lived secrets. In production, use a generated ID instead of the filename in the key, and check the real file type in the worker, since the browser's claimed type can lie.

A real example

Example

Uploads that survived a slow train connection

A legal-AI startup handled uploads by streaming files through its API to a server disk. Large contracts on slow connections timed out, and files vanished whenever a container restarted. They switched to presigned multipart uploads straight to a private bucket (resumable from the browser), with an "object created" event feeding an ingestion queue. Workers checked file signatures, scanned for malware, extracted text and indexed it, updating a status the UI showed live. Upload failures fell from about 7% to under 0.5%, and API servers no longer needed large disks or long timeouts.

The easy way

Start here

Create a private bucket, add an API endpoint that validates the request and returns a presigned PUT URL, upload from the browser with fetch(url, {method: "PUT", body: file}), then call /uploads/{id}/complete to enqueue processing.

When you grow: the production setup

Production setups use multipart and resumable uploads, storage events into queues, file-signature checks and malware scanning, per-tenant key prefixes and encryption, lifecycle and retention rules, object versioning, presigned downloads with short expiry, size quotas per plan, and audit logs of access to sensitive files.

Trade-offs

What you gain

  • Scales to huge files without loading your API.
  • Durable, cheap storage independent of servers.
  • Clear separation between upload and processing.

What it costs you

  • More moving parts (storage, events, workers, status).
  • Presigned URLs need careful scoping and expiry.
  • Validation must happen twice (request and actual file).

When not to use it

  • Tiny files (a few KB) in a simple internal tool can go through the API directly.

Common mistakes

  • Public buckets.
  • Trusting the client's filename and content type.
  • Processing uploads inside the upload request.
  • Storing files on container disks.
  • Long-lived presigned URLs shared in logs or chats.

Interview questions

Try answering out loud first, then open the model answer.

Design file uploads for a document Q&A product.

Model answer

The client requests an upload; the API authenticates, validates type, size and quota, creates a document record and a safe storage key, and returns a presigned (multipart) URL; the browser uploads directly to a private bucket; a storage event or completion call enqueues ingestion; workers verify the file type, scan for malware, parse, chunk, embed and index with tenant and ACL metadata, updating status that the UI shows; originals are retained under lifecycle rules.

What are presigned URLs and what are their risks?

Model answer

URLs signed with your credentials that grant temporary permission for one action on one object (upload or download) without sharing keys. Risks: anyone holding the URL can use it until it expires, so keep expiry short, scope to exact keys and content types, avoid logging them, and validate the uploaded object afterwards.

How do you secure user file uploads?

Model answer

Authenticate and authorise the upload, limit size and types, generate storage keys server-side, keep buckets private and encrypted, verify actual file signatures after upload, scan for malware, process files in isolated workers, scope data by tenant, set retention rules, and log access.

Quiz

Pick an answer to see whether it’s right and why.

  1. 01What is a presigned URL?
  2. 02Why upload directly to object storage instead of through your API?
  3. 03What should happen after the file lands in the bucket?
  4. 04Why not use the user's filename as the storage key directly?
  5. 05Should upload buckets be public?

0 of 5 answered

Key terms

Object storage
Cheap, durable storage for files of any size, organised as objects in buckets, such as Amazon S3, Google Cloud Storage or Azure Blob Storage.
Presigned URL
A short-lived URL signed by your server that allows one specific upload or download from object storage without sharing credentials.
Amazon S3
Amazon's object storage service; many other stores offer an S3-compatible API.
Multipart upload
Uploading a large file in parts that can be sent in parallel and retried individually, then combined into one object.
Content-Type
The header that says what kind of data a body contains, such as application/json, application/pdf or text/event-stream.

Before and after

Progress is saved in this browser only.