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:
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.

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:
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.
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.
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.
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.
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.
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.
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.
FileField and the Django storage API backed by S3.Python support in Encore is in beta, and you can sign up for access to try it with your Django project.
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.
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.
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.
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.