Skip to content

Media Storage

SpeedPy stores uploaded media on local disk by default. That is deliberate: a disk or a platform volume needs no credentials, no bucket and no CDN, and it is the right choice until you actually outgrow it.

When you do outgrow it, you flip one flag. Nothing in the codebase is tied to a specific provider — the provider is chosen entirely by S3_ENDPOINT_URL.

This page is about uploaded media. Static assets are a separate concern and stay on WhiteNoise in both modes; see Static Files.

When to move off local disk

Move to object storage when any of these becomes true:

  • You run more than one web container, so an upload on one is invisible to the other.
  • Your platform's filesystem is ephemeral, and uploads disappear on redeploy.
  • You want a CDN in front of user media.
  • You need signed, expiring URLs for files that must not be public.

Until then, local disk is simpler and cheaper, and there is nothing to configure.

Default: local disk

Media lands in MEDIA_ROOT and is served at MEDIA_URL. Nothing to set.

On Appliku

Attach a volume and set its environment variable prefix to MEDIA. Appliku then derives two variables from that prefix and sets them for you:

Prefix you type Variables Appliku sets Value
MEDIA MEDIA_ROOT the volume's container path
MEDIA_URL the volume's web-server path

Those are the exact names project/settings.py reads.

# appliku.yml
volumes:
  media:
    target: "/media/"
    url: "/media/"
    environment_variable: "MEDIA"

If a volume has no web-server path

Appliku still sets <PREFIX>_URL, and the value arrives as the literal string "None". SpeedPy normalizes that (and an empty value) back to /media/, and guarantees the trailing slash Django requires. See project/media.py.

Switching to S3-compatible storage

boto3 is large, so object storage is an optional dependency:

uv sync --extra s3

Then set:

USE_S3=True
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_BUCKET_NAME=my-bucket
S3_REGION_NAME=...
S3_ENDPOINT_URL=...        # empty for AWS S3
S3_CDN_BASE=...            # optional CDN or custom domain for public files
S3_DEFAULT_ACL=            # see below — empty works everywhere

USE_S3=True without key, secret and bucket refuses to boot, rather than failing later on the first upload.

Verify against the real bucket

python manage.py check_storage

It uploads a public probe and fetches it by plain URL, uploads a private probe and asserts it is refused without a signature, then fetches it with one, and cleans up after itself.

The middle check is the one that earns its keep. A bucket that is public by mistake passes the other two while quietly serving private files to anyone who has the URL.

Per-provider settings

Provider S3_ENDPOINT_URL S3_DEFAULT_ACL Notes
AWS S3 (empty) (empty) Buckets created since April 2023 have ACLs disabled; grant public read with a bucket policy.
DigitalOcean Spaces https://<region>.digitaloceanspaces.com public-read Supports per-object ACLs. Enable the CDN and set S3_CDN_BASE.
Cloudflare R2 https://<account>.r2.cloudflarestorage.com (empty) No ACL support at all. Use a public bucket or custom domain, and set S3_CDN_BASE.
Wasabi https://s3.<region>.wasabisys.com public-read
Backblaze B2 https://s3.<region>.backblazeb2.com (empty)
MinIO / self-hosted https://minio.example.com (empty) Also set S3_ADDRESSING_STYLE=path.

ACLs are the one genuinely non-portable part

S3_DEFAULT_ACL is empty by default, which works on every provider.

Sending public-read where ACLs are disabled does not degrade quietly — the provider rejects the upload. So set it only where per-object ACLs exist (DigitalOcean Spaces, Wasabi, older AWS buckets). Everywhere else, make the bucket or prefix readable with a bucket policy and leave this empty.

The two backends

speedpycom/storages.py defines two backends sharing one bucket, with disjoint prefixes and opposite policies:

Backend Prefix Access
PublicMediaStorage media/ Plain URLs, through S3_CDN_BASE when set. Wired as STORAGES["default"].
PrivateMediaStorage private/ Signed URLs only, expiring after S3_SIGNED_URL_EXPIRE seconds (default 600).

Credentials are passed to boto3 explicitly rather than left to its environment lookup, so the AWS_SES_* variables used for email can never silently redirect uploads.

Private files that work in both modes

Use the private_storage callable and a model field stops caring which mode you are in:

from project.media import private_storage

class Invoice(models.Model):
    pdf = models.FileField(storage=private_storage, upload_to="invoices/")

Pass the function itself, not a call. Django accepts a callable and records the reference in migrations, so flipping USE_S3 later needs no migration.

  • USE_S3=Truefield.url returns a signed, expiring URL.
  • Local disk — files land in PRIVATE_MEDIA_ROOT, which defaults outside MEDIA_ROOT, because everything under MEDIA_ROOT is served by the web server. field.url raises ValueError on purpose: serve the file through a view that checks permissions and returns FileResponse(field.open()).

Do not put private files under MEDIA_ROOT

A private/ subdirectory inside MEDIA_ROOT is still served by the web server. Django's FileSystemStorage also falls back to MEDIA_URL when given no base URL, so simply omitting one is not enough either. Both traps are why PRIVATE_MEDIA_ROOT and PrivateFileSystemStorage exist.

Static files do not move

Static stays on WhiteNoise whether or not USE_S3 is on. Deploys stay atomic, there is no collectstatic round-trip to object storage, and no CDN invalidation step on every release. Only uploaded media moves.

Migrating existing files

Switching USE_S3 changes where new uploads go. It does not move old ones. Copy them first, then flip the flag:

# example: rclone, with a remote configured for your provider
rclone copy /path/to/media remote:my-bucket/media

FileField values are stored as paths relative to the backend, so they keep resolving once the objects exist under the same media/ prefix.