Back to blog
By Rasul

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:

  1. Classify static, public media, and private media separately.
  2. Configure a named storage alias for each access contract.
  3. Choose workload identity, bucket/CDN policy, and signed delivery.
  4. Generate collision-safe object keys and decide overwrite behavior.
  5. Copy and checksum-verify existing media before switching reads.
  6. Canary the destination, cut over, and monitor missing or denied objects.
  7. Retain a rollback source and run the provider smoke tests.

On this page

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:

  1. Inventory database file names and source files. Record missing, duplicate, and unsafe names before copying.
  2. Define the destination key mapping. Preserve existing relative names unless you also run a deliberate database migration.
  3. Bulk-copy the initial set with encryption, content type, and required metadata. Do not copy a legacy public ACL into an ACL-disabled bucket.
  4. 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.
  5. 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.
  6. Exercise reads from the destination in a canary environment, including private authorization and expired signatures.
  7. Switch reads, monitor missing-key and authorization failures, and keep the source read-only through a defined rollback window.
  8. 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 staticfiles alias exists and collectstatic --noinput succeeds;
  • 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.
  • default and staticfiles are 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.