Django S3 Storage: Public and Private Media
Configure Django S3 storage for public and private media, signed URLs, safe file naming, migration, rollback, and production tests.
For Django 4.2 and later, configure Django S3 storage under
STORAGES["default"] and keep STORAGES["staticfiles"] separate. For modern
Amazon S3 buckets, leave object ACLs unset, grant access through policies or
short-lived signed URLs, and copy existing objects before switching reads.
This avoids three high-impact failures: exposing private files, breaking
collectstatic, and leaving old uploads behind.
A working settings dictionary is only the start. You also need an explicit contract for access, object names, migration, rollback, and tests. This guide builds that contract and shows where the current Djass-generated S3 baseline fits.
The production workflow is:
- Classify static, public media, and private media separately.
- Configure a named storage alias for each access contract.
- Choose workload identity, bucket/CDN policy, and signed delivery.
- Generate collision-safe object keys and decide overwrite behavior.
- Copy and checksum-verify existing media before switching reads.
- Canary the destination, cut over, and monitor missing or denied objects.
- Retain a rollback source and run the provider smoke tests.
On this page
- Should Django S3 media be public or private?
- How do you configure S3 with Django STORAGES?
- Why does public-read fail on a new S3 bucket?
- How do you serve private Django files from S3?
- Does S3 overwrite files with the same name?
- How do you migrate Django media to S3 safely?
- How do you test Django S3 storage?
- What changes for MinIO, R2, or Spaces?
- What S3 configuration does Djass generate?
- What should you check before switching Django to S3?
Should Django S3 media be public or private?
Static assets and user uploads are different jobs. Hashed CSS and JavaScript can be public and cached for a long time. An invoice, identity document, or private export must pass application authorization before access or a signed URL is granted. A public avatar may be readable without a signature but still needs stable naming and deletion rules.
Write the contract before writing settings:
| Asset class | Example | Read policy | Name policy | Cache policy |
|---|---|---|---|---|
| Static | hashed CSS/JS | public CDN | content hash | long-lived, immutable |
| Public media | product image | public policy or CDN | unique, non-overwriting | bounded by replacement needs |
| Private media | invoice PDF | app authorization plus signed URL | unique, non-overwriting | private, short-lived URL |
| Derived private file | data export | authorized owner only | immutable job ID | private, expire or delete |
Do not put all four classes behind one global public-read option. Separate
aliases, prefixes, or buckets make policy and lifecycle mistakes easier to
contain.
Django gives two aliases special meaning. The default alias backs managed
files such as FileField and ImageField; staticfiles backs
collectstatic. Defining STORAGES replaces Django's defaults rather than
merging with them, so omitting staticfiles can produce the
staticfiles.E005 system check. See the Django STORAGES setting
and storage API.
default_storage is Django's lazy accessor for the backend registered under
the default alias; it is not a third storage configuration.
How do you configure S3 with Django STORAGES?
Install the dependency, then lock its exact version with your project's dependency manager:
python -m pip install "django-storages[s3]"
The current django-storages documentation uses
storages.backends.s3.S3Storage. Older examples built around
DEFAULT_FILE_STORAGE, STATICFILES_STORAGE, or the old s3boto3 import path
should not be copied into a modern settings module.
django-storages 1.14.6 documents the modern alias API, but its PyPI classifiers stop at Django 5.1. Treat Django 6 compatibility as something your pinned project verifies in CI, not as vendor certification.
Start with private media and keep WhiteNoise, or another existing backend, for static files:
import os
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": os.environ["AWS_S3_BUCKET_NAME"],
"region_name": os.environ["AWS_S3_REGION_NAME"],
"location": "media/private",
"default_acl": None,
"querystring_auth": True,
"querystring_expire": 300,
"file_overwrite": False,
},
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
For intentionally public user media, add a separate alias. Keeping default
private means an accidental omission fails closed:
STORAGES["public_media"] = {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": os.environ["AWS_S3_BUCKET_NAME"],
"region_name": os.environ["AWS_S3_REGION_NAME"],
"location": "media/public",
"default_acl": None,
"querystring_auth": False,
"file_overwrite": False,
},
}
Grant anonymous reads only to media/public/* through a bucket or CDN policy,
then make the storage choice explicit on the model:
from django.core.files.storage import storages
from django.db import models
def public_media_storage():
return storages["public_media"]
class Product(models.Model):
image = models.ImageField(storage=public_media_storage, upload_to="products/")
Keep the callable at a stable import path so migrations can serialize it.
Let boto3 use the platform's workload role or temporary credentials when possible. AWS recommends temporary credentials and least-privilege permissions instead of distributing long-lived access keys. Local development can use a named profile or carefully managed environment variables, but application code should not contain credentials. The AWS IAM best practices support the security recommendation; the django-storages authentication settings document its credential lookup behavior.
If static files also belong in S3, give them a separate alias and prefix:
STORAGES["staticfiles"] = {
"BACKEND": "storages.backends.s3.S3ManifestStaticStorage",
"OPTIONS": {
"bucket_name": os.environ["AWS_STATIC_BUCKET_NAME"],
"region_name": os.environ["AWS_S3_REGION_NAME"],
"location": "static",
"default_acl": None,
"querystring_auth": False,
"file_overwrite": True,
},
}
The manifest backend creates content-hashed copies, but the original paths are
stored too. Apply long-lived immutable caching only to the hashed outputs—for
example at the CDN or in a storage subclass that recognizes manifest names—not
as one global object parameter. This example also assumes a bucket policy or
CDN grants public reads. Setting
querystring_auth to False only removes authentication parameters from the
generated URL; it does not grant s3:GetObject. If the origin stays private,
use CloudFront Origin Access Control, its matching bucket policy, and
custom_domain configured for the distribution instead.
AWS recommends OAC over the legacy Origin Access Identity for private S3
origins in its CloudFront guide.
Why does public-read fail on a new S3 bucket?
New S3 buckets default to Bucket owner enforced, which disables ACLs. An
upload that sends public-read can fail with
AccessControlListNotSupported. AWS recommends keeping ACLs disabled and using
IAM and bucket policies for most current use cases. Its Object Ownership documentation
describes the exact behavior.
That creates an important compatibility check for copied settings. In
django-storages, default_acl=None means the backend does not send an ACL unless
object_parameters supplies an explicit ACL value. It
does not by itself prove that an object is private: bucket policy, access-point
policy, CloudFront, and Block Public Access still determine reachability.
Keep all four S3 Block Public Access settings enabled for private media. AWS applies the most restrictive combination across organization, account, bucket, and access point, so changing one bucket toggle may not change the effective policy. Treat public delivery as an architecture choice, not a troubleshooting shortcut. The Block Public Access reference is the source of truth.
New S3 uploads already receive SSE-S3 encryption at rest by default, as the AWS default encryption FAQ documents. That is a baseline, not an access policy. Use a customer-managed KMS key only when your threat model, audit requirements, and key-recovery operations justify the extra policy surface.
How do you serve private Django files from S3?
A private FileField.url can return a presigned S3 URL when
querystring_auth=True. The application should first authorize the user, then
generate or redirect to a short-lived URL:
from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponseRedirect
@login_required
def download_invoice(request, invoice_id):
invoice = request.user.invoices.filter(id=invoice_id).first()
if invoice is None:
raise Http404
return HttpResponseRedirect(invoice.pdf.url)
The signature is not authorization logic. A presigned URL is a bearer token that authorizes one scoped operation using the signing principal's permissions. Anyone who receives it can use it until it expires, credentials expire, or policy revokes access. Keep expiry proportional to the download, avoid logging full query strings, and never place a long-lived signed URL in a permanent page or email. AWS documents these limits in Sharing objects with presigned URLs.
AWS Signature Version 4 (SigV4) is the protocol boto3 uses to bind the signed request to its method, object key, selected headers, region, credentials, and expiry.
For large browser uploads, you can authorize an upload in Django and issue a
presigned PUT or POST so the browser sends bytes directly to object storage.
That is a separate architecture from Django's default upload handlers. Bind
the key to the authenticated owner and create a pending database record. Use a
presigned POST policy when the browser must be constrained with conditions such
as content-length-range; a typical presigned PUT does not enforce a maximum
body size by itself. Finalize only after the server verifies the bucket, key,
size, checksum, and detected type. A presigned PUT to an existing key replaces
the object, so the server must choose a fresh key.
Use the Django file upload production pipeline to implement the reservation record, Django-versus-direct transport decision, layered validation, scanning, finalization, and abandoned-object cleanup that sit above this storage configuration.
CORS matters only when a browser directly calls an S3 origin from another
origin. Configure only the intended origins, methods, and request headers for
the workflow; S3 also supports limited wildcard matching. CORS does
not make an object public and does not replace IAM or a signature. A URL that
works with curl but fails in a browser is often a CORS mismatch; read the
AWS S3 CORS guide
before changing access policy.
Does S3 overwrite files with the same name?
django-storages defaults file_overwrite to True. Saving the same key again
therefore replaces its current S3 object. With False, the backend adds
characters to find an available name and returns the actual saved name. These
options are documented in the django-storages 1.14.6 S3 settings.
For user uploads, a collision-resistant application key is clearer than relying on a human filename:
from pathlib import PurePath
from uuid import uuid4
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".pdf"}
def private_upload_to(instance, filename):
extension = PurePath(filename).suffix.lower()
safe_extension = extension if extension in ALLOWED_EXTENSIONS else ""
return f"accounts/{instance.account_id}/{uuid4()}{safe_extension}"
Do not use the client filename as proof of type, and sanitize or discard it before displaying it. If two workers intentionally write one fixed key, S3 applies last-writer-wins, as described in the S3 consistency model. Versioning can help recover a previous object, but it does not serialize concurrent writers or make a multi-object update atomic.
Remote storage also breaks local-path assumptions. Django's Storage.path()
raises NotImplementedError when the file is not locally accessible. Audit
image processors, PDF libraries, antivirus scanners, and code that calls
field.path or Python's open() before cutover. Use the storage API, stream to
a bounded temporary file, or choose a library that accepts a file-like object.
How do you migrate Django media to S3 safely?
Changing STORAGES["default"] does not move bytes. A FileField usually stores
a relative name such as avatars/42/photo.jpg. After the switch, Django asks
the new backend for that same name. If you did not copy the object to the
matching S3 key, the database row remains valid while the read fails. S3 may
return 403 rather than 404 for a missing key when the caller lacks list access.
Use a staged migration:
- Inventory database file names and source files. Record missing, duplicate, and unsafe names before copying.
- Define the destination key mapping. Preserve existing relative names unless you also run a deliberate database migration.
- Bulk-copy the initial set with encryption, content type, and required metadata. Do not copy a legacy public ACL into an ACL-disabled bucket.
- Verify object count, total bytes, and checksums. An ETag is not always a whole-object MD5, especially after multipart uploads; use the S3 object integrity guidance. Keep a reconciliation manifest with model and primary key, stored name, destination key, byte count, checksum algorithm and value, copy time, and verification status.
- Handle writes during the backfill with a maintenance window, a journaled second pass, or a temporary dual-write layer. Dual write needs failure reconciliation; it is not free safety.
- Exercise reads from the destination in a canary environment, including private authorization and expired signatures.
- Switch reads, monitor missing-key and authorization failures, and keep the source read-only through a defined rollback window.
- Roll back by restoring the old backend while its names and bytes still match. Reconcile any objects written after cutover before trying again.
Enable S3 Versioning before the cutover if accidental overwrite or deletion is a material risk. It preserves earlier variants, but every version is a full, billable object. Add lifecycle rules for noncurrent versions and abandoned multipart uploads instead of treating versioning as free backup. See the S3 Versioning guide.
How do you test Django S3 storage?
Test behavior, not just a settings dictionary. A minimal contract suite should prove:
storages["default"]can save, reopen, and delete a generated object name;- the returned name is stored on the model and a same-name upload does not replace private media unexpectedly;
- an unauthorized user cannot reach the application download endpoint;
- an authorized user receives a signed URL and the URL expires as designed;
- code paths do not require a local
.path; - the
staticfilesalias exists andcollectstatic --noinputsucceeds; - a direct browser upload accepts the intended origin, method, headers, type, and POST policy size range while rejecting a different contract;
- the selected S3-compatible provider supports signed GET, signed PUT, delete, multipart, CORS, and metadata behavior you actually use.
Keep unit tests fast with an in-memory or filesystem backend, then run a small provider contract suite against an isolated test bucket. Mocking boto3 proves your code called a mock; it cannot prove that bucket policy, region, signature, endpoint, or CORS are correct.
Use a deployment smoke test that writes a random key, checks exists(), opens
and verifies the content, requests the appropriate URL, and deletes the key.
Never reuse a production customer key for this test.
When something fails, narrow it by layer:
| Symptom | First checks |
|---|---|
403 AccessDenied |
workload identity, bucket policy, explicit denies, Block Public Access, unexpected ACL |
AccessControlListNotSupported |
remove public-read; bucket has ACLs disabled |
SignatureDoesNotMatch |
HTTP method, signed headers/content type, clock, expiry, region, proxy mutation |
ExpiredToken or early expiry |
signing session lifetime and bucket signature-age policy |
AuthorizationQueryParametersError |
custom endpoint plus missing or mismatched region |
| Works in CLI, fails in browser | exact CORS origin, method, requested headers, cached preflight |
| Old uploads fail or appear missing | copied key differs from the name stored in Django |
collectstatic system check fails |
missing or invalid staticfiles alias |
Monitor S3 request volume and 4xxErrors around deployment. Also alert on the
application outcomes that matter: rejected uploads, missing objects, expired
download attempts, and migration mismatches. Do not log credentials, complete
signed URLs, or private object content.
What changes for MinIO, R2, or Spaces?
S3-compatible does not mean feature-identical. With MinIO, Cloudflare R2,
DigitalOcean Spaces, or another provider, configure a scheme-bearing
endpoint_url and set the provider's required region_name. The
django-storages documentation recommends an explicit region with a custom
endpoint to avoid AuthorizationQueryParametersError.
"OPTIONS": {
"bucket_name": os.environ["AWS_S3_BUCKET_NAME"],
"endpoint_url": os.environ["AWS_S3_ENDPOINT_URL"],
"region_name": os.environ["AWS_S3_REGION_NAME"],
"default_acl": None,
"file_overwrite": False,
}
| Provider | Endpoint and signing region | Access caveat |
|---|---|---|
| Amazon S3 | no custom endpoint; actual bucket region | new buckets disable ACLs and enable Block Public Access |
| Cloudflare R2 | account endpoint; region auto |
ACL fields are unsupported; verify the R2 compatibility matrix |
| DigitalOcean Spaces | regional endpoint and provider region | support is partial; verify the Spaces compatibility matrix |
Test the operations your application needs. Providers can differ on ACLs,
addressing style, signature details, multipart behavior, presigned POST, CORS,
metadata, and custom domains. A successful PutObject is not proof that signed
downloads, browser uploads, deletion, and rollback will work.
What S3 configuration does Djass generate?
Djass exposes Use S3 in its generator options and documents the corresponding storage environment variables. The current generated starter selects local filesystem storage when the S3 endpoint is blank and an S3-compatible backend when it is set. The runtime switch is the endpoint value, not the generator checkbox by itself. Static assets stay on WhiteNoise and filename overwrite is disabled for media, which is a sensible split for a generated SaaS repository.
There is one access-policy decision you must make deliberately. As inspected
on 2026-08-04, the generated S3 media options use public-read and unsigned
URLs. That is a public-media baseline and can conflict with new AWS buckets
whose ACLs are disabled. For private uploads, remove the public ACL, retain
signed access, and put authorization in an application view. For public media
on AWS, prefer an explicit bucket/CDN policy rather than re-enabling legacy
object ACLs without a clear reason.
These findings come from the generated settings.py, .env.example,
docker-compose-local.yml, and test_settings.py at the inspected starter
commit e240ab67 (2026-08-02). The same inspection found three inputs to make explicit before Amazon S3
deployment. The generated bucket name is currently derived from project slug
and environment rather than the documented bucket variable. Its region is
currently eu-east-1, which is not an AWS region code, so use the bucket's
actual AWS region. And local Docker needs separate internal and browser-facing
endpoint handling: Django reaches a Compose service name while a host browser
reaches a published port. Align the MinIO bootstrap and application credentials
and run a real provider smoke test; current unit tests deliberately replace S3
with filesystem storage.
This is why a generator option cannot choose the final policy for you. Your data classification determines whether the object is public, signed, or only streamed through Django. The generated repository structure gives an agent or developer the settings and deployment boundaries to inspect, while the project generation pipeline explains how those choices become a repository. Check the current Djass pricing when you want to generate that baseline rather than assemble it from scratch.
What should you check before switching Django to S3?
Before switching production media to S3, confirm all of these:
- Every asset class has an owner, read policy, retention policy, and cache policy.
defaultandstaticfilesare separate, valid storage aliases.- Workloads use temporary credentials and least-privilege actions.
- Private buckets keep Block Public Access enabled and do not send object ACLs.
- Public delivery is granted through an explicit bucket or CDN policy.
- Private downloads authorize in Django before issuing a short-lived URL.
- Object keys are collision-resistant and overwrite behavior is intentional.
- Remote-file consumers no longer assume
.path()exists. - Existing file names and bytes are copied and checksum-verified before reads switch.
- Writes during migration have a journal, freeze, or reconciliation plan.
- Rollback keeps the old backend readable for a bounded window.
- Unit, provider-contract,
collectstatic, authorization, CORS, and expiry tests pass. - Versioning, lifecycle, monitoring, and redaction rules are configured.
The durable design is not “Django uploads to S3.” It is: each class of file has one deliberate access path, one naming rule, one migration path, and a test that proves the contract still holds.
Django S3 storage FAQ
Should Django static files and media share a bucket?
They can, but separate prefixes or buckets make access, caching, lifecycle, and incident response clearer. Public immutable static assets and private mutable uploads should not inherit the same policy by accident.
Are django-storages uploads private by default?
The S3 backend's default_acl defaults to None and signed query parameters
default to enabled. Effective privacy still depends on IAM, bucket policy,
Block Public Access, access points, and any CDN configuration.
Does changing STORAGES migrate existing files?
No. Django keeps the relative file name in the database and asks the active backend for that name. Copy the corresponding objects and verify them before switching reads.
Why does a presigned URL expire earlier than configured?
Its life cannot exceed the credentials used to create it. Temporary role credentials may expire before the requested URL duration. Policy conditions can also impose a shorter signature age.
Why does FieldFile.path fail with S3?
S3 has no local filesystem path. Django's storage API allows remote backends to
raise NotImplementedError for path(). Open the file through its storage
backend or stream it into a bounded temporary file.