Upload Files Directly to S3 from Django with Presigned URLs
Let browsers upload large files straight to S3 with presigned POST policies: size and type limits, bucket CORS, server-side confirmation, FileField wiring, and cleanup of abandoned uploads.
Problem statement
When users upload videos, large PDFs, or datasets through a normal Django form, the file travels browser → proxy → Gunicorn → Django → S3. Every hop has a size limit and a timeout, a worker is occupied for the whole upload, and on platforms with request timeouts (Heroku, Cloud Run, many load balancers) large uploads simply fail.
Direct uploads remove Django from the data path. Django issues a short-lived, signed upload policy; the browser sends the file straight to S3; then Django confirms the upload and records it. This guide implements that flow with limits that S3 enforces, so the signed policy cannot be abused.
- Configure bucket CORS to allow
POSTfrom your site's origin. - Add a Django view that authenticates the user, generates an object key server-side, and returns
generate_presigned_postwith acontent-length-rangecondition, a fixedContent-Type, and a short expiry. - In the browser, POST the returned fields plus the file (as the last field) to the returned URL.
- Call a confirmation endpoint with the key; Django checks the object with
head_object(size, type) and saves the key on aFileField. - Upload into a temporary prefix and expire abandoned objects with a lifecycle rule.
Step-by-step solution
1. Bucket CORS
[
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Location"],
"MaxAgeSeconds": 3000
}
]
aws s3api put-bucket-cors --bucket myapp-media --cors-configuration file://cors.json
Keep the bucket's public access block enabled. Presigned uploads do not require any public access.
2. IAM permissions
The credentials Django uses to sign the policy must be allowed to perform the upload themselves, because the signature grants their permissions:
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::myapp-media/uploads/*"
}
Add s3:ListBucket on the bucket itself if you want head_object to return 404 rather than 403 for missing keys.
3. Issue the upload policy
# uploads/views.py
import uuid
import boto3
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_POST
s3 = boto3.client("s3", region_name=settings.AWS_S3_REGION_NAME)
ALLOWED_TYPES = {"application/pdf": ".pdf", "video/mp4": ".mp4"}
MAX_BYTES = 500 * 1024 * 1024
@login_required
@require_POST
def create_upload(request):
content_type = request.POST.get("content_type", "")
if content_type not in ALLOWED_TYPES:
return JsonResponse({"error": "unsupported type"}, status=400)
key = f"uploads/tmp/{request.user.pk}/{uuid.uuid4().hex}{ALLOWED_TYPES[content_type]}"
post = s3.generate_presigned_post(
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
Key=key,
Fields={"Content-Type": content_type},
Conditions=[
{"Content-Type": content_type},
["content-length-range", 1, MAX_BYTES],
],
ExpiresIn=300,
)
return JsonResponse({"key": key, "url": post["url"], "fields": post["fields"]})
- The key is generated by the server, so users cannot overwrite other objects or choose paths.
- Conditions are enforced by S3. A request with a different content type or a size outside the range is rejected by S3, not by your code.
- Five minutes is enough to start an upload; S3 checks the expiry when the request begins.
- The view uses POST and Django's normal CSRF protection.
4. Upload from the browser
async function uploadFile(file) {
const csrf = document.querySelector("[name=csrfmiddlewaretoken]").value;
const policyRes = await fetch("/uploads/create/", {
method: "POST",
headers: { "X-CSRFToken": csrf },
body: new URLSearchParams({ content_type: file.type }),
});
if (!policyRes.ok) throw new Error("upload not allowed");
const { key, url, fields } = await policyRes.json();
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v));
form.append("file", file); // must be the last field
const s3Res = await fetch(url, { method: "POST", body: form });
if (!s3Res.ok) throw new Error(`S3 rejected upload: ${s3Res.status}`);
const confirmRes = await fetch("/uploads/confirm/", {
method: "POST",
headers: { "X-CSRFToken": csrf },
body: new URLSearchParams({ key }),
});
return confirmRes.json();
}
S3 ignores form fields after the file, so the file must come last. For progress bars, use XMLHttpRequest and its upload.onprogress event instead of fetch.
5. Confirm and attach the file
# uploads/views.py (continued)
from botocore.exceptions import ClientError
from documents.models import Document
@login_required
@require_POST
def confirm_upload(request):
key = request.POST.get("key", "")
if not key.startswith(f"uploads/tmp/{request.user.pk}/"):
return JsonResponse({"error": "invalid key"}, status=400)
try:
head = s3.head_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key)
except ClientError:
return JsonResponse({"error": "upload not found"}, status=404)
if head["ContentLength"] > MAX_BYTES or head["ContentType"] not in ALLOWED_TYPES:
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key)
return JsonResponse({"error": "invalid upload"}, status=400)
final_key = key.replace("uploads/tmp/", "uploads/files/", 1)
s3.copy_object(
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
CopySource={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": key},
Key=final_key,
)
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key)
doc = Document(owner=request.user)
doc.file.name = final_key # FileField whose storage is the same bucket
doc.save()
return JsonResponse({"id": doc.pk})
Setting file.name attaches the existing object without re-uploading it. copy_object handles objects up to 5 GB in one call; for larger files use boto3's managed copy().
The Content-Type in the object's metadata is what the client declared, constrained by the policy. It does not prove the file's actual content. If you need that guarantee, inspect the first bytes (a ranged get_object) or run a scanning step before marking the document usable.
6. Clean up abandoned uploads
Uploads that are never confirmed stay in uploads/tmp/. Expire them automatically:
{
"Rules": [{
"ID": "expire-tmp-uploads",
"Filter": {"Prefix": "uploads/tmp/"},
"Status": "Enabled",
"Expiration": {"Days": 1},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 1}
}]
}
aws s3api put-bucket-lifecycle-configuration --bucket myapp-media --lifecycle-configuration file://lifecycle.json
Verification check:
- An upload within limits succeeds and the document record points at
uploads/files/.... - An upload larger than
MAX_BYTESis rejected by S3 with400(EntityTooLarge). - Changing the file's content type in the form causes S3 to reject it with
403(policy condition failed). - Gunicorn's access log shows only the small policy and confirm requests.
Explanation
A presigned POST is a policy document (bucket, key, allowed fields, conditions, expiry) signed with your credentials. S3 validates the signature and every condition before accepting the upload, so the browser can talk to S3 directly while your server still decides what may be uploaded, where, and how large. The confirm step closes the loop: the database only references objects that exist and passed your checks.
Edge cases / notes
Presigned PUT versus POST
A presigned PUT URL is simpler but cannot enforce a size range. Use POST policies when size limits matter, which is almost always for user uploads.
Very large files
Above a few hundred megabytes, multipart uploads are more reliable: each part is presigned separately and can be retried. This needs more client code or a library.
Other S3-compatible storage
Cloudflare R2, DigitalOcean Spaces, and others support presigned URLs, but support for POST policies varies; R2, for example, supports presigned PUT but not POST policy uploads. Check your provider before choosing the flow.
When to automate this
The CORS rules, lifecycle rule, and IAM policy belong in infrastructure code. The policy and confirm views are worth wrapping in a small reusable app with tests for size, type, and key-prefix enforcement, since the same pattern tends to spread across features.
Next steps
- Set up storage first with How to Store Django Media Files on Amazon S3.
- If S3 refuses uploads, see Fix AccessDenied Errors Uploading Django Files to S3.
- For uploads that still go through the proxy, see Fix Nginx 413 Request Entity Too Large for Django Uploads.
- Review the rest with the Django File Upload Security Checklist.
FAQ
Why upload directly to S3 instead of through Django?
Large uploads through Django occupy workers and hit proxy size limits and platform timeouts. Direct uploads send the bytes to S3 while Django only signs and verifies.
How do I limit file size with presigned uploads?
Use a presigned POST with a content-length-range condition. S3 rejects files outside the range.
Why does S3 return a CORS error in the browser?
The bucket has no CORS rule allowing POST from your site's origin. Add one with put-bucket-cors.
How do I attach an uploaded S3 object to a Django FileField?
Set instance.file.name to the object key, with the field's storage pointing at the same bucket, and save the model.