Contents
In October 2025 I moved a production Django app’s entire file-storage layer from AWS S3 to DigitalOcean Spaces. Pet photos, prescription PDFs, clinic logos, the Excel billing exports — all of it. The app had been live for six years by then, serving real veterinary clinics in India, and it had gone the whole of 2024 without a single commit.
Here’s the part worth writing about: the migration touched zero Python. No model changed. No view, no serializer, no upload handler. I changed a handful of environment variables, rebuilt the Docker image for linux/amd64, promoted it, and wrote a one-page runbook. Done.
That is not because S3 and Spaces are secretly the same product. It’s because of a small, slightly-unconventional decision I made years earlier — early in my career, for reasons that had nothing to do with a migration I couldn’t have predicted at the time. This post is about that decision, why it paid off, and how to set yourself up to make the same swap cheaply.
The conventional move, and the one I made instead
When you wire django-storages up to S3, the docs point you at the minimum viable setup: install boto3, set DEFAULT_FILE_STORAGE to the S3 backend, drop in your AWS keys, done. Every uploaded file lands in one bucket, one prefix, one ACL.
That works. It’s also a shape that’s annoying to change later, because you’ve spread implicit assumptions — this bucket is public, URLs never expire, static and media share a namespace — across a backend you don’t own.
I didn’t do that. I wrote three thin subclasses instead, one per kind of thing I store:
# upload/storages.py
from storages.backends.s3boto3 import S3Boto3Storage
class StaticStorage(S3Boto3Storage):
location = "static"
default_acl = "public-read"
querystring_auth = False # static URLs are never signed
class PublicMediaStorage(S3Boto3Storage):
location = "media/public"
default_acl = "public-read"
file_overwrite = False # two uploads named avatar.jpg must not collide
querystring_auth = False
class PrivateMediaStorage(S3Boto3Storage):
location = "media/private"
default_acl = "private"
file_overwrite = False
querystring_auth = True # prescriptions get short-lived signed URLs
custom_domain = False # never serve private files off the public CDN
At the time, the reason was concern-separation, not portability. A clinic logo and a patient’s prescription have genuinely different rules: one is public and cacheable forever, the other needs an expiring signed URL and must never hit a CDN edge. I didn’t want that logic bleeding into every model’s FileField. Three subclasses let a model just say where a file belongs and forget the rest:
class Prescription(models.Model):
pdf = models.FileField(storage=PrivateMediaStorage())
class Clinic(models.Model):
logo = models.ImageField(storage=PublicMediaStorage())
The portability turned out to be a side effect. But it was the side effect that mattered.
Why the subclass boundary is also a vendor seam
Here’s the mechanism. All three subclasses inherit from S3Boto3Storage, which sits on boto3. And boto3 doesn’t actually care whether it’s talking to AWS — it talks to whatever endpoint_url you give it. DigitalOcean Spaces, MinIO, Backblaze B2, Cloudflare R2: they all speak the S3 API. Spaces in particular is close to a drop-in.
So the subclass is a single chokepoint where the vendor is described entirely by configuration. Every knob those classes read — bucket, keys, region, endpoint, custom domain — comes from Django settings, and every one of those settings reads from an environment variable:
# settings.py (Django 3.1 era — string-based storage settings)
import os
AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
AWS_STORAGE_BUCKET_NAME = os.environ["AWS_STORAGE_BUCKET_NAME"]
# Unset on plain AWS -> boto3 hits real S3. Set it -> boto3 hits Spaces.
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL")
AWS_S3_REGION_NAME = os.environ.get("AWS_S3_REGION_NAME", "us-east-1")
USE_SPACES_CDN = os.environ.get("USE_SPACES_CDN", "false").lower() == "true"
AWS_S3_CUSTOM_DOMAIN = os.environ["SPACES_CDN_DOMAIN"] if USE_SPACES_CDN else None
STATICFILES_STORAGE = "upload.storages.StaticStorage"
DEFAULT_FILE_STORAGE = "upload.storages.PublicMediaStorage"
Nothing above names a vendor. It names capabilities — an endpoint, a region, a bucket, an optional CDN domain. That’s the whole trick. Once the vendor is fully captured by config, changing the vendor is a config change.
The swap itself
This is the entire code-side diff for moving from AWS S3 to Spaces in the blr1 (Bangalore) region:
# Before — AWS S3
AWS_ACCESS_KEY_ID=<aws-key>
AWS_SECRET_ACCESS_KEY=<aws-secret>
AWS_STORAGE_BUCKET_NAME=vetclinic-media
AWS_S3_REGION_NAME=ap-south-1
# AWS_S3_ENDPOINT_URL intentionally unset -> boto3 talks to real S3
USE_SPACES_CDN=false
# After — DigitalOcean Spaces
AWS_ACCESS_KEY_ID=<spaces-key>
AWS_SECRET_ACCESS_KEY=<spaces-secret>
AWS_STORAGE_BUCKET_NAME=vetclinic
AWS_S3_REGION_NAME=blr1
AWS_S3_ENDPOINT_URL=https://blr1.digitaloceanspaces.com
USE_SPACES_CDN=true
SPACES_CDN_DOMAIN=vetclinic.blr1.cdn.digitaloceanspaces.com
The load-bearing line is AWS_S3_ENDPOINT_URL. On AWS you leave it blank and boto3 resolves the real S3 endpoints itself; set it, and every request — PUT, GET, signed-URL generation — goes to Spaces instead. Because the request shapes are identical S3 API calls, the storage subclasses never know the difference.
The CDN toggle
USE_SPACES_CDN decides whether public URLs point at the Spaces origin or the CDN edge, by flipping AWS_S3_CUSTOM_DOMAIN. When it’s on, PublicMediaStorage and StaticStorage build URLs against the CDN domain — so a clinic logo is served from an edge cache, not the bucket. PrivateMediaStorage deliberately pins custom_domain = False, so signed prescription URLs always go to origin. You do not want a short-lived signed URL landing on a CDN that might cache it, and you don’t want private objects cached at the edge at all.
That one toggle is why, on the day of the move, the static assets came up on the Spaces CDN without any per-file fiddling — collectstatic had already pushed them, and the URL builder just started pointing at the edge.
The part env vars don’t cover — be honest
The swap makes your code point at Spaces. It does not move your existing bytes. There are two categories, and they’re handled differently:
Static files: no copy needed. collectstatic regenerates and re-uploads them from your build. Run it against the new bucket and you’re done — that’s why the deploy runbook could just note the static files as “already on the CDN.” They were rebuilt, not migrated.
User media: you have to copy it. Every pet photo and prescription that already existed lives in the old bucket. Since both old and new are S3 endpoints, rclone with two S3 remotes copies bucket-to-bucket in one command, server-side where the providers support it:
rclone copy aws-s3:vetclinic-media do-spaces:vetclinic --checksum
The sequencing that avoids downtime: copy media first while the old bucket is still serving traffic, then flip the environment variables, verify a couple of URLs resolve, and only then decommission the old bucket. Don’t forget to set the bucket’s CORS and default ACL on the Spaces side before you cut over — that’s the one thing that isn’t inherited from your Django config.
A note for modern Django
This app is pinned to Django 3.1, so it uses the string-based DEFAULT_FILE_STORAGE / STATICFILES_STORAGE settings you see above. On Django 4.2+, those are replaced by the unified STORAGES dict — but the pattern is identical. You still write the three subclasses; you just register them under STORAGES keys instead. The seam doesn’t move.
What the abstraction actually bought
I want to be straight about the growth arc here, because the flattering version of this story would be that I foresaw a vendor migration in 2019. I didn’t. I wrote those subclasses because splitting public from private from static felt tidy, and because I didn’t want signed-URL logic smeared across a dozen models. That was the whole motivation at the time.
The lesson I actually took away, years later, is that the tidiness and the portability were the same property wearing two hats. “Every model’s storage rules live in one file” and “the vendor is fully described by config” are the same discipline. And that discipline generalizes: the identical reflex had put the email backend behind plain SMTP settings, which is why, in that same October 2025 window, moving transactional email from SendGrid to Resend was also just a config change and an image rebuild.
Two vendor migrations in one week, both env-var swaps, on a codebase I hadn’t meaningfully touched in over a year. Neither needed a scary weekend because the seams were already there.
So the practical takeaway is cheaper than it sounds: put third-party infra behind a single, config-described seam on day one — a storage subclass, an SMTP block, a broker URL. It costs you almost nothing while you’re building. And every once in a while, the payoff shows up half a decade later as a migration that fits on one page.
