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.
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:
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¶
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=True—field.urlreturns a signed, expiring URL.- Local disk — files land in
PRIVATE_MEDIA_ROOT, which defaults outsideMEDIA_ROOT, because everything underMEDIA_ROOTis served by the web server.field.urlraisesValueErroron purpose: serve the file through a view that checks permissions and returnsFileResponse(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.