Store Django Media Files on Cloudflare R2
Store Django uploads on Cloudflare R2 with django-storages: scoped API tokens, STORAGES settings, custom domains, signed URLs, and migration.
Problem statement
User uploads should not live on an application server's disk once you run more than one server, deploy with containers, or use a platform with an ephemeral filesystem. Object storage solves that. Cloudflare R2 is S3-compatible storage without egress fees, which makes it attractive for sites that serve many images or downloads.
Because R2 speaks the S3 API, django-storages' S3 backend works with it. The differences from Amazon S3 are where deployments break:
- R2 does not support S3 object ACLs, so
public-readuploads fail or are ignored - the endpoint is account-specific, and the region must be
auto - public access works through a connected custom domain or the rate-limited
r2.devsubdomain, not through bucket policies - browser uploads need a CORS policy on the bucket
This guide stores Django media files in R2, serves public files from a custom domain, keeps private files behind signed URLs, and migrates existing uploads.
Quick answer
- Create an R2 bucket and an R2 API token with Object Read and Write permission scoped to that bucket.
- Install
django-storages[s3]. - Configure
STORAGES["default"]withstorages.backends.s3.S3Storage,endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com",region_name="auto", anddefault_acl=None. - For public files, connect a custom domain to the bucket and set
custom_domainwithquerystring_auth=False. - For private files, use a second storage with
querystring_auth=Trueand a shortquerystring_expire. - Set
file_overwrite=False, add CORS if browsers upload directly, and copy existing media withrclone. - Verify upload, URL generation, and access from a browser.
Step-by-step solution
1. Create the bucket and credentials
In the Cloudflare dashboard, open R2 Object Storage:
- Create a bucket, for example
myapp-media. Choose a location hint near your users or servers if offered. - Under Manage API tokens, create a token with Object Read & Write permission, restricted to the
myapp-mediabucket. - Copy the Access Key ID and Secret Access Key (shown once) and note your account ID from the S3 API endpoint.
Scope the token to the single bucket. A token with account-wide access turns a leaked Django environment into access to every bucket.
2. Install django-storages
pip install "django-storages[s3]"
The s3 extra installs boto3.
3. Configure public media storage
import os
R2_ACCOUNT_ID = os.environ["R2_ACCOUNT_ID"]
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": os.environ["R2_BUCKET"],
"endpoint_url": f"https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com",
"region_name": "auto",
"access_key": os.environ["R2_ACCESS_KEY_ID"],
"secret_key": os.environ["R2_SECRET_ACCESS_KEY"],
"signature_version": "s3v4",
"default_acl": None,
"file_overwrite": False,
"custom_domain": "media.example.com",
"querystring_auth": False,
"object_parameters": {"CacheControl": "public, max-age=86400"},
},
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
Settings that matter for R2:
endpoint_urlpoints boto3 at your account's R2 S3 endpoint.region_name="auto"is the region value R2 expects.default_acl=Nonestops django-storages from sending an ACL header. Access control is configured on the bucket in Cloudflare, not per object.file_overwrite=Falseappends a random suffix when a name already exists, so one user's upload namedavatar.jpgnever replaces another's.custom_domainmakesFileField.urlreturnhttps://media.example.com/<key>instead of a signed API endpoint URL.querystring_auth=Falsestops signing URLs for public files, which keeps URLs stable and cacheable.
Keep MEDIA_URL consistent with the domain if templates build URLs manually; prefer {{ obj.file.url }} so the storage generates them.
4. Make the bucket publicly readable through a custom domain
In the bucket's Settings, under public access, connect a custom domain such as media.example.com from a zone in the same Cloudflare account. Cloudflare creates the DNS record and certificate. Files are then served through Cloudflare's network, with its caching and security features.
The r2.dev public URL is intended for development and is rate limited; do not use it for production traffic.
Only put files in this bucket that anyone with the URL may see. Random suffixes make URLs hard to guess but do not make them private.
5. Private files with signed URLs
For invoices, exports, and other user-specific documents, use a separate bucket without public access, and a storage class that signs URLs:
from django.conf import settings
from storages.backends.s3 import S3Storage
class PrivateMediaStorage(S3Storage):
def __init__(self, **kwargs):
kwargs.update(
bucket_name=settings.R2_PRIVATE_BUCKET,
endpoint_url=f"https://{settings.R2_ACCOUNT_ID}.r2.cloudflarestorage.com",
region_name="auto",
access_key=settings.R2_ACCESS_KEY_ID,
secret_key=settings.R2_SECRET_ACCESS_KEY,
signature_version="s3v4",
default_acl=None,
file_overwrite=False,
querystring_auth=True,
querystring_expire=300,
)
super().__init__(**kwargs)
from django.db import models
from core.storage import PrivateMediaStorage
class Invoice(models.Model):
pdf = models.FileField(storage=PrivateMediaStorage, upload_to="invoices/%Y/%m/")
invoice.pdf.url returns a presigned URL valid for five minutes. Generate it only inside a view that has checked the user may access the invoice, and do not cache the rendered URL longer than it is valid. The API token must have access to this bucket as well.
6. CORS for direct browser uploads
If the browser uploads directly to R2 with presigned PUT URLs, add a CORS policy on the bucket, for example:
[
{
"AllowedOrigins": ["https://www.example.com"],
"AllowedMethods": ["PUT", "GET"],
"AllowedHeaders": ["Content-Type"],
"MaxAgeSeconds": 3600
}
]
Uploads that pass through Django (a normal form post) do not need CORS, because the server talks to R2, not the browser.
7. Migrate existing media
Copy files from the server's MEDIA_ROOT with rclone, keeping keys identical to the names stored in your database:
# ~/.config/rclone/rclone.conf
[r2]
type = s3
provider = Cloudflare
access_key_id = ...
secret_access_key = ...
endpoint = https://ACCOUNT_ID.r2.cloudflarestorage.com
acl = private
rclone copy /srv/myapp/media r2:myapp-media --progress --checksum
rclone check /srv/myapp/media r2:myapp-media --one-way
FileField stores the path relative to the storage root, such as avatars/2026/01/photo.jpg, so copying the directory tree keeps existing database values valid. Run a final sync right before switching storage settings to catch files uploaded during the migration.
8. Verify
python manage.py shell -c "
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
name = default_storage.save('healthcheck/r2-test.txt', ContentFile(b'ok'))
print(name)
print(default_storage.url(name))
print(default_storage.exists(name))
default_storage.delete(name)
"
Then fetch the printed URL with curl -I before deleting, and upload a file through the application's real form.
Verification check:
save()succeeds andexists()returnsTrue- public URLs use
https://media.example.com/and return200 - private file URLs contain signature parameters and stop working after expiry
- uploading two files with the same name produces two different keys
- old uploads display correctly after the migration
Rollback
Switch STORAGES["default"] back to FileSystemStorage and restore files uploaded to R2 since the switch back to disk with rclone copy r2:myapp-media /srv/myapp/media. Because keys match relative paths, database values do not change.
Explanation
django-storages abstracts file storage behind Django's storage API, so models and forms do not change when files move to R2. The configuration's job is to describe R2's S3 dialect (endpoint, auto region, no ACLs) and to choose a URL strategy per bucket: stable unsigned URLs through a custom domain for public media, and short-lived presigned URLs for private files.
Edge cases / notes
Checksum errors with newer boto3 versions
Recent boto3 releases send additional integrity checksums by default. If an S3-compatible provider rejects them, uploads fail with signature or header errors. Check the provider's current guidance; setting the environment variables AWS_REQUEST_CHECKSUM_CALCULATION=when_required and AWS_RESPONSE_CHECKSUM_VALIDATION=when_required restores the previous behavior.
Image processing libraries
Thumbnail libraries that read the original file download it from R2 on each generation. Cache generated thumbnails in R2 and store their names, rather than regenerating on every request.
Deleting a model does not delete its file
Django does not delete files when a model instance is deleted. Clean up orphaned objects with a periodic job or lifecycle rules.
Troubleshooting R2 with django-storages
| Error | Cause | Fix |
|---|---|---|
InvalidAccessKeyId or SignatureDoesNotMatch | wrong credentials, or credentials from a different account | recreate the token; check the account ID in endpoint_url |
AccessDenied on upload | token lacks write permission or is scoped to another bucket | edit token permissions and bucket scope |
NoSuchBucket | bucket name typo or different account | list buckets with the same credentials |
Uploads work, public URLs return 401 or 403 | no public access configured | connect a custom domain to the bucket |
URLs point at r2.cloudflarestorage.com with signatures | custom_domain not set or querystring_auth still True | set both options for the public storage |
NotImplemented or header errors on upload | a feature or header R2 does not support, such as ACLs | set default_acl=None; review object_parameters |
Test credentials outside Django with the AWS CLI pointed at R2, which isolates configuration problems from application code:
AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \
aws s3 ls s3://myapp-media \
--endpoint-url "https://ACCOUNT_ID.r2.cloudflarestorage.com" \
--region auto
Upload size and direct uploads
Uploads through Django pass through your web server and application process. For large files, generate presigned upload URLs so the browser sends the file straight to R2:
import boto3
from django.conf import settings
def presigned_upload_url(key, content_type, expires=300):
client = boto3.client(
"s3",
endpoint_url=f"https://{settings.R2_ACCOUNT_ID}.r2.cloudflarestorage.com",
aws_access_key_id=settings.R2_ACCESS_KEY_ID,
aws_secret_access_key=settings.R2_SECRET_ACCESS_KEY,
region_name="auto",
)
return client.generate_presigned_url(
"put_object",
Params={"Bucket": settings.R2_BUCKET, "Key": key, "ContentType": content_type},
ExpiresIn=expires,
)
Generate key on the server (for example uploads/<user-id>/<uuid>.jpg) rather than trusting a client-supplied filename, validate content type and expected size, and record the key in the database only after confirming the object exists.
Costs and lifecycle
R2 charges for storage and for operations (class A for writes and lists, class B for reads), but not for egress. Operation counts matter more than with egress-priced storage:
- avoid calling
storage.exists()orsizeon every page render; store metadata in your models - configure object lifecycle rules to delete temporary uploads (for example an
uploads/tmp/prefix) after a few days
Next steps
- Compare with the AWS setup in How to Store Django Media Files on Amazon S3.
- Troubleshoot permission errors with Fix AccessDenied Errors Uploading Django Files to S3.
- Protect uploads with Back Up Django Media Files.
FAQ
Does django-storages support Cloudflare R2?
Yes. Use the S3 backend with R2's account-specific endpoint_url, region_name="auto", R2 API token credentials, and default_acl=None.
Why do uploads to R2 fail with ACL settings?
R2 does not support S3 object ACLs such as public-read. Set default_acl to None and control public access with a connected custom domain on the bucket.
How do I serve public Django media from R2?
Connect a custom domain to the bucket in Cloudflare, then set custom_domain to that hostname and querystring_auth=False so Django generates stable public URLs.
Can I use R2 for Django static files too?
Yes, with a separate storage configuration and collectstatic uploading to a bucket. Many sites keep static files on WhiteNoise or a CDN and use R2 mainly for user uploads.