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-read uploads 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.dev subdomain, 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.

Django media on Cloudflare R2UploadFileField savedjango-storagesS3 API to R2R2 bucketobject storageCustom domainpublic or signed URLUploads leave the server entirely, so any instance can serve the same files.

Quick answer

  1. Create an R2 bucket and an R2 API token with Object Read and Write permission scoped to that bucket.
  2. Install django-storages[s3].
  3. Configure STORAGES["default"] with storages.backends.s3.S3Storage, endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com", region_name="auto", and default_acl=None.
  4. For public files, connect a custom domain to the bucket and set custom_domain with querystring_auth=False.
  5. For private files, use a second storage with querystring_auth=True and a short querystring_expire.
  6. Set file_overwrite=False, add CORS if browsers upload directly, and copy existing media with rclone.
  7. 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:

  1. Create a bucket, for example myapp-media. Choose a location hint near your users or servers if offered.
  2. Under Manage API tokens, create a token with Object Read & Write permission, restricted to the myapp-media bucket.
  3. Copy the Access Key ID and Secret Access Key (shown once) and note your account ID from the S3 API endpoint.
R2 setup before DjangoBucket createdone per environmentAPI token scoped to that bucketObject Read & WriteAccount endpoint noted<account>.r2.cloudflarestorage.comKeys stored as secretsnever in settings filesAn account-wide token can read every bucket; scope itdjango-deployment.com
Region is always "auto" for R2.

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",
    },
}
S3Storage pointed at R2"endpoint_url": f"https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com","region_name":"auto","signature_version": "s3v4","custom_domain": "media.example.com","querystring_auth":False,12341The S3 API endpoint for your account.2R2 has no regions to choose.3Public URLs through Cloudflare’s cache.4Plain URLs: only for public media.Same S3Storage backend as AWS; only the endpoint differsdjango-deployment.com
Private files need a second storage with signed URLs.

Settings that matter for R2:

  • endpoint_url points boto3 at your account's R2 S3 endpoint.
  • region_name="auto" is the region value R2 expects.
  • default_acl=None stops django-storages from sending an ACL header. Access control is configured on the bucket in Cloudflare, not per object.
  • file_overwrite=False appends a random suffix when a name already exists, so one user's upload named avatar.jpg never replaces another's.
  • custom_domain makes FileField.url return https://media.example.com/<key> instead of a signed API endpoint URL.
  • querystring_auth=False stops 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.

serve media from your own domainr2.dev public URLmeant for development✕rate limited✕no Cloudflare cache featuresmedia.example.com✓cached at the edge✓WAF and rules apply✓URLs survive a provider moveA custom domain requires the zone to be on Cloudflaredjango-deployment.com
Stored file URLs in the database then never mention R2.

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)
two storages, two access modelsURLSettingsPublic mediacustom domain, cachedquerystring_auth FalsePrivate files✓signed, expiringquerystring_auth True, expire 300Use a separate bucket for private files so a domain rule cannot expose themdjango-deployment.com
Pick the storage per FileField with the storage argument.
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 and exists() returns True
  • public URLs use https://media.example.com/ and return 200
  • 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

ErrorCauseFix
InvalidAccessKeyId or SignatureDoesNotMatchwrong credentials, or credentials from a different accountrecreate the token; check the account ID in endpoint_url
AccessDenied on uploadtoken lacks write permission or is scoped to another bucketedit token permissions and bucket scope
NoSuchBucketbucket name typo or different accountlist buckets with the same credentials
Uploads work, public URLs return 401 or 403no public access configuredconnect a custom domain to the bucket
URLs point at r2.cloudflarestorage.com with signaturescustom_domain not set or querystring_auth still Trueset both options for the public storage
NotImplemented or header errors on uploada feature or header R2 does not support, such as ACLsset default_acl=None; review object_parameters
R2 errors, decodedErrorUsual causeInvalidAccessKeyIdtoken from another account, or typoSignatureDoesNotMatchwrong endpoint or signature versionChecksum or header errors on uploadnewer boto3 checksum defaults403 on the public URLcustom domain not connectedTest the same keys with the AWS CLI and --endpoint-url firstdjango-deployment.com
The explanation above covers the boto3 checksum setting.

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() or size on 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

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.

Keep going