Oct 26, 20267 min read

How to Add S3 File Uploads to a Django App

Storing user files from Django in S3 or Google Cloud Storage with Encore, django-storages or boto3 presigned URLs, with direct browser uploads, private buckets, CSRF and local testing.

Django stores uploads on the local disk by default, which breaks once the app runs on several containers or an ephemeral filesystem. Moving files to S3 or Google Cloud Storage fixes that and brings bucket policies, IAM credentials, presigned URLs and CORS with it.

This guide stores invoice PDFs from a Django app in a bucket. It compares Encore, django-storages and boto3 presigned URLs.

What you need to decide:

  • Whether files pass through Django or go straight from the browser to the bucket.
  • How downloads stay private.
  • How the app gets permission to the bucket without long-lived keys.
  • What bucket developers and CI use.

File storage options for Django at a glance

CapabilityEncoredjango-storagesboto3 presigned URLs
APIBucket.upload, download, signed URLsDjango Storage and FileFieldS3 client calls
Direct browser uploadssigned_upload_urlNot built ingenerate_presigned_url or presigned POST
Local bucketencore run provides oneLocal disk or a separate S3 emulatorLocalStack or a dev bucket
CredentialsIAM role created per serviceKeys or the runtime's roleKeys or the runtime's role
Production bucketCreated in your AWS or GCP accountYou create itYou create it

Encore: object storage for your existing Django project

Encore

Encore combines a bucket declared in your Django code with the storage behind it, a local bucket under encore run and S3 on AWS or Cloud Storage on GCP after deploy, along with the permissions the service needs to use it. It is an infrastructure SDK added to an existing project, and views, models and the admin stay standard Django.

Encore declares infrastructure in Python code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in Python, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

The bucket is declared next to the database in documents/resources.py:

from encore.storage import objects, postgres # Django manages the schema through its own migrations. db = postgres.Database("documents") # Encore finds this declaration and provisions the bucket. invoices = objects.Bucket("invoices")

The views cover the two upload paths and the two download paths. Small files go through upload_invoice, larger ones go straight from the browser to the URL that invoice_upload_url returns, and downloads are either streamed by Django or handed out as a signed link that expires after an hour:

from django.http import HttpRequest, HttpResponse, JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST, require_http_methods from encore.storage import objects from .resources import invoices @csrf_exempt # Stateless API example without cookie auth. @require_http_methods(["PUT"]) async def upload_invoice(request: HttpRequest, invoice_id: int) -> JsonResponse: attrs = await invoices.upload( f"{invoice_id}.pdf", request.body, content_type="application/pdf" ) return JsonResponse({"key": attrs.name, "size": attrs.size}) @csrf_exempt @require_POST async def invoice_upload_url(request: HttpRequest, invoice_id: int) -> JsonResponse: # The browser PUTs the file to this URL, which expires after ten minutes. url = await invoices.signed_upload_url(f"{invoice_id}.pdf", ttl=600) return JsonResponse({"url": url}) @require_GET async def invoice_download_url(request: HttpRequest, invoice_id: int) -> JsonResponse: key = f"{invoice_id}.pdf" if not await invoices.exists(key): return JsonResponse({"detail": "no invoice"}, status=404) url = await invoices.signed_download_url(key, ttl=3600) return JsonResponse({"url": url}) @require_GET async def download_invoice(request: HttpRequest, invoice_id: int) -> HttpResponse: try: data = await invoices.download(f"{invoice_id}.pdf") except objects.ObjectNotFound: return JsonResponse({"detail": "no invoice"}, status=404) return HttpResponse(data, content_type="application/pdf")

The views are wired up in the URL configuration:

from django.urls import path from documents import views urlpatterns = [ path("invoices/<int:invoice_id>", views.upload_invoice), path("invoices/<int:invoice_id>/upload-url", views.invoice_upload_url), path("invoices/<int:invoice_id>/url", views.invoice_download_url), path("invoices/<int:invoice_id>/file", views.download_invoice), ]

encore run provides a local bucket that issues working signed URLs, so the browser upload flow can be tested without an AWS account:

~/documents
$ encore run$ curl -X PUT localhost:4000/invoices/42 --data-binary @invoice.pdf{"key": "42.pdf", "size": 21}$ curl localhost:4000/invoices/42/url{"url": "http://127.0.0.1:44973/objects/invoices/42.pdf?X-Goog-Algorithm=..."}$ curl localhost:4000/invoices/7/url{"detail": "no invoice"}

On deploy, Encore creates the bucket in S3 or Google Cloud Storage in your own account and grants the service's role access to the bucket, so there are no access keys in settings or environment variables. Preview environments for each pull request get a bucket of their own, separate from staging and production, and the local development dashboard shows each request with its trace.

Because the bucket is a line of Python, an agent asked to add invoice uploads writes the declaration and the views, and runs the upload against the local bucket. Cloud access sits with Encore's IAM role, not the agent.

Good to know

The views here check nothing about who is asking. In a real app, load the invoice from the database and check it belongs to request.user before uploading or signing a URL, or anyone can guess IDs. The two csrf_exempt endpoints suit token-authenticated API clients, and views behind session cookies should keep Django's CSRF protection and send the X-CSRFToken header from the browser. Browser uploads to a signed URL also need CORS rules on the bucket for your site's origin.

Try Encore →

django-storages

django-storages plugs S3 into Django's storage API, so FileField and ImageField write to the bucket:

# settings.py import os STORAGES = { "default": { "BACKEND": "storages.backends.s3.S3Storage", "OPTIONS": { "bucket_name": os.environ["INVOICES_BUCKET"], "region_name": "eu-central-1", "default_acl": None, "querystring_auth": True, "querystring_expire": 3600, "file_overwrite": False, }, }, "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, }

With django-storages, the model field keeps its usual shape and the storage backend decides where the bytes go:

# models.py from django.db import models class Invoice(models.Model): owner = models.ForeignKey("auth.User", on_delete=models.CASCADE) pdf = models.FileField(upload_to="invoices/")

invoice.pdf.url then returns a signed URL, because querystring_auth is on.

Good to know

Uploads through a form still pass through Django, so large files hold a worker for the whole transfer. With no keys in settings, boto3 picks up the task or instance role instead of reading access keys from the environment. The storage calls are synchronous, so async views need sync_to_async around them.

boto3 presigned URLs

For direct browser uploads without a storage backend, boto3 generates presigned URLs:

import os import boto3 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") BUCKET = os.environ["INVOICES_BUCKET"] @login_required @require_POST def invoice_upload_url(request, invoice_id: int): url = s3.generate_presigned_url( "put_object", Params={"Bucket": BUCKET, "Key": f"{invoice_id}.pdf", "ContentType": "application/pdf"}, ExpiresIn=600, ) return JsonResponse({"url": url})

This view sits behind session authentication, so it keeps CSRF protection and the browser sends the token with the POST.

Good to know

A presigned PUT cannot cap the file size, while a presigned POST can, with a content-length-range condition. A presigned URL is only as good as the credentials that signed it, and one signed with a task role's temporary credentials stops working when they expire, even inside its ExpiresIn.

Handling uploads safely

Check the type and size of each file rather than trusting the extension or the client's content type, as the OWASP File Upload Cheat Sheet recommends, and generate the object key on the server instead of using the uploaded filename. Keep S3's Block Public Access on and give each environment its own bucket. With Encore, buckets per environment and the access between service and bucket come from the declaration, and cloud access sits with Encore's IAM role in your account.

How to choose

  • Encore if you want a bucket that runs locally and in S3 or Cloud Storage in your account from one declaration, with signed URLs and no keys to manage.
  • django-storages if you want FileField and the Django storage API backed by S3.
  • boto3 presigned URLs if you want direct browser uploads and full control over each S3 call.

Python support in Encore is in beta, and you can sign up for access to try it with your Django project.

Frequently asked questions

How do I upload files to S3 from Django?

Either stream the file through a view into the bucket, or issue a presigned upload URL so the browser sends the file straight to S3. With Encore, declare invoices = objects.Bucket("invoices"), upload with await invoices.upload(name, request.body, content_type="application/pdf"), and create links with await invoices.signed_upload_url(name, ttl=600) and signed_download_url(name, ttl=3600). encore run gives you a local bucket, and on deploy the bucket is created in S3 or Google Cloud Storage in your own account.

Should Django proxy uploads or use presigned URLs?

Presigned URLs keep large files off your web workers and out of request timeouts, which matters most on Fargate, Cloud Run and Lambda. Proxying through a view is simpler for small files and lets the view validate the content before it is stored.

How do I keep Django uploads private on S3?

Block public access on the bucket and hand out short-lived signed download URLs from a view that checks the user is allowed to see the file. Never make a bucket of user documents public to save a request.

Can I use Django's FileField with S3?

Yes. django-storages provides an S3 storage backend, and setting it in STORAGES makes FileField and ImageField write to the bucket instead of the local disk.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders