Store Django Media Files on DigitalOcean Spaces

Store Django uploads on DigitalOcean Spaces with django-storages’ S3 backend: set the regional endpoint, create scoped access keys, choose private files with presigned URLs or public files behind the Spaces CDN, and configure CORS.

Problem statement

Django apps on DigitalOcean App Platform have ephemeral filesystems, and apps on Droplets lose uploads when the Droplet is rebuilt or scaled out to several machines. DigitalOcean Spaces is S3-compatible object storage with a built-in CDN, priced simply. Because it speaks the S3 API, django-storages' S3 backend works with a few settings: the regional endpoint URL, access keys, and choices about public versus private files.

Media on SpacesDjango uploadFileField saveS3 backendendpoint_url to SpacesSpaces bucketprivate or public-readCDN or presignedserve to browserSpaces speaks the S3 API, so django-storages only needs the endpoint and keys.
  1. Create a Space in the region closest to your app, and an access key (preferably scoped to that bucket).
  2. pip install "django-storages[s3]".
  3. Configure S3Storage with endpoint_url="https://<region>.digitaloceanspaces.com", bucket_name, region_name, and the keys from environment variables.
  4. Keep default_acl="private" and querystring_auth=True for private uploads; use a separate public Space with the CDN for public assets.
  5. Set CORS on the Space if browsers upload directly.
  6. Use unique upload_to paths so files never overwrite each other.

Step-by-step solution

1. Create the Space and keys

In the DigitalOcean control panel, create a Space (for example myapp-media in fra1), leave file listing restricted, and enable the CDN only for the public Space. Create an access key under API → Spaces Keys; newer accounts can scope keys to specific buckets with read/write permissions, which limits damage if the key leaks.

2. Configure django-storages

STORAGES = {
    "default": {
        "BACKEND": "storages.backends.s3.S3Storage",
        "OPTIONS": {
            "bucket_name": "myapp-media",
            "endpoint_url": "https://fra1.digitaloceanspaces.com",
            "region_name": "fra1",
            "access_key": env("SPACES_KEY"),
            "secret_key": env("SPACES_SECRET"),
            "default_acl": "private",
            "querystring_auth": True,
            "querystring_expire": 600,
            "file_overwrite": False,
            "object_parameters": {"CacheControl": "max-age=86400"},
        },
    },
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

The endpoint is the region URL, not the bucket URL; the backend adds the bucket. With querystring_auth, file.url returns a presigned URL valid for 10 minutes.

two Spaces, two purposesPrivate SpacePublic SpaceACLprivatepublic-readURL type✓presigned, expiringstable CDN URLCDNoff✓on, custom domainExample contentinvoices, documentsavatars, product imagesSeparate Spaces keep public caching from leaking private filesdjango-deployment.com
Use a second storage alias in STORAGES for the public Space.

3. Public assets through the CDN

For public files, add a second storage:

STORAGES["public"] = {
    "BACKEND": "storages.backends.s3.S3Storage",
    "OPTIONS": {
        "bucket_name": "myapp-public",
        "endpoint_url": "https://fra1.digitaloceanspaces.com",
        "access_key": env("SPACES_KEY"), "secret_key": env("SPACES_SECRET"),
        "default_acl": "public-read", "querystring_auth": False,
        "custom_domain": "cdn.example.com",
    },
}
from django.core.files.storage import storages
avatar = models.ImageField(upload_to=avatar_path, storage=lambda: storages["public"])

Point a CNAME at the Spaces CDN endpoint and add the custom domain with a certificate in the Spaces settings.

4. CORS for direct uploads

If the browser uploads directly with presigned POST or PUT URLs, add a CORS rule on the Space allowing your origin and the PUT/POST methods with the needed headers.

5. Keys and environment

Store keys as App Platform encrypted environment variables or in your Droplet's environment file. Rotate them if they appear in logs or a repository.

Spaces setup for DjangoRegion endpoint_url, not bucket URLhttps://fra1.digitaloceanspaces.comBucket-scoped access keyfrom environment variablesPrivate ACL and presigned URLsfor user documentsSeparate public Space with CDNcustom domain, cache headersUnique upload pathsfile_overwrite offThe S3 backend plus the right endpoint is all Spaces needsdjango-deployment.com
Keep the Space in the same region as the app for speed and free internal transfer.

Verification check: upload through the admin and confirm the object appears in the Space; the page link contains X-Amz-Signature and expires; public assets load from cdn.example.com with a cache header.

Explanation

Spaces implements the S3 API, including signature version 4 authentication, presigned URLs, ACLs, and multipart uploads, so S3 clients such as boto3 work by pointing them at a different endpoint. django-storages' S3 backend therefore covers Spaces without a separate implementation. The Spaces CDN caches public objects at edge locations; private objects stay behind presigned URLs that Django issues after checking permissions.

Edge cases / notes

  • Bucket names are global per region endpoint and must be DNS-compatible.
  • Per-object ACLs default to private; make sure the backend does not set public-read on private uploads.
  • Listing performance on very large Spaces is slow; avoid code that lists directories at request time.
  • Backups. Spaces does not version objects by default in all regions; copy important media elsewhere with rclone.

Next steps

FAQ

Does django-storages support DigitalOcean Spaces?

Yes, through the S3 backend with the Spaces regional endpoint URL.

What endpoint URL should I use?

The region endpoint such as https://fra1.digitaloceanspaces.com, not the bucket subdomain.

How do I serve private files from Spaces?

Keep objects private and let django-storages generate presigned URLs.

Can I use the Spaces CDN with Django?

Yes, for public files: enable the CDN on a public Space and set custom_domain.

Keep going