Run your own S3-compatible storage without fooling yourself about durability: bucket policies and least privilege, presigned URLs, direct browser uploads, why a single node is not a backup, mirroring, lifecycle rules, and when a managed provider wins.
Object storage has become the default home for user uploads. The interface won: a single HTTP API, implemented by every cloud provider and by several products you can run yourself, which means the same application code works against all of them.
That portability is what makes self-hosting reasonable. This guide covers running an S3-compatible store on your own infrastructure, wiring Django to it, and — the part usually skipped — being honest about what a self-hosted store does and does not protect you against.
A local directory works until the day it does not. It ties uploads to one machine, so scaling to a second application server means either shared network storage or an awkward synchronisation problem. It grows without limit on a disk sized for an operating system. And it makes migration painful, because the data lives outside both the repository and the database.
Object storage separates the data from the machine. Files are addressed by key over HTTP, accessible from any number of application servers, backed by storage that grows independently, and straightforward to mirror elsewhere.
The reason to care about the S3 API specifically is that it is the lingua franca. Your Django application talks to one interface; behind it can sit a hyperscaler, a European provider, or a process on your own server. Switching means changing an endpoint and credentials, not rewriting file handling.
That is worth designing for even if you never switch. A dependency you can replace in an afternoon is a fundamentally different kind of dependency from one that would take a quarter.
A single-node deployment is a container, a data directory and two ports — one for the API, one for the browser console.
services:
storage:
image: quay.io/minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${STORAGE_ROOT_USER:?}
MINIO_ROOT_PASSWORD: ${STORAGE_ROOT_PASSWORD:?}
volumes:
- storage_data:/data
networks: [internal]
restart: unless-stopped
Note what is absent: no published ports. The application reaches it over the internal network, and if browsers must fetch objects directly, a reverse proxy exposes the API endpoint over TLS. An object store reachable from the internet on a plain port is an invitation.
The root user exists to create other users. It should never appear in your application's configuration. Create a dedicated account per application, grant it access to exactly one bucket, and store those credentials — not the root ones — in the application's environment.
mc alias set local http://storage:9000 "$ROOT_USER" "$ROOT_PASSWORD"
mc mb local/myapp-media
mc admin user add local myapp-service "$SERVICE_SECRET"
mc admin policy attach local readwrite --user myapp-service
The stock read-write policy is broader than you want, because it covers every bucket. Write a policy scoped to the single bucket instead — the difference matters the day an application key leaks, when the question is whether the attacker reached one bucket or all of them.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::myapp-media", "arn:aws:s3:::myapp-media/*"]
}]
}
Two resource entries are required and people routinely supply one. The bucket itself is the target for listing; the wildcard path is the target for object operations. Omit the first and listing fails with a permission error that looks like a credential problem.
Django's storage backend abstraction means the application code does not change. Configure the
backend and the endpoint, and FileField keeps working exactly as before.
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": env("STORAGE_BUCKET"),
"endpoint_url": env("STORAGE_ENDPOINT"),
"region_name": "us-east-1",
"signature_version": "s3v4",
"addressing_style": "path",
"file_overwrite": False,
"querystring_auth": True,
"querystring_expire": 300,
},
},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
Two settings deserve attention. Path-style addressing is usually required for self-hosted stores,
because virtual-host style expects wildcard DNS for bucket names. And file_overwrite set
to false prevents a new upload from silently replacing an existing object with the same name.
With querystring_auth enabled, Django generates time-limited signed URLs rather than
permanent public links. The bucket stays private and access is granted per request, for a few
minutes.
This is the right default for anything a user uploaded. It is the wrong default for a public marketing image, where every page render produces a fresh URL that no cache or CDN can reuse — and the link stops working when it expires, which is confusing if someone copied it.
Use two buckets: one private with signed access for user content, one public with plain URLs for assets that are genuinely public. Trying to serve both from one bucket produces a compromise that is wrong for both.
By default an upload travels twice: browser to your application, application to storage. For large files that consumes a worker for the duration and doubles bandwidth.
A presigned upload URL removes the middle hop. Your application authorises the upload; the browser sends the bytes straight to the store.
import boto3
def presigned_upload(request):
client = boto3.client("s3", endpoint_url=settings.STORAGE_ENDPOINT)
url = client.generate_presigned_url(
"put_object",
Params={"Bucket": "myapp-media", "Key": f"uploads/{uuid4()}.bin"},
ExpiresIn=300,
)
return JsonResponse({"url": url})
Constrain what you sign: generate the key server-side rather than accepting one from the client, and set a short expiry. A presigned URL is a bearer token — anyone holding it can perform the operation you authorised.
This is where self-hosted object storage is most often oversold. A single-node deployment on one disk has exactly the durability of that disk. The API is identical to a cloud provider's; the guarantees behind it are not.
Managed object storage typically replicates every object across multiple independent failure domains. Your single node does not. It is convenient, portable and cheap — and if the disk fails, the objects are gone.
Multi-node deployments with erasure coding do provide real redundancy, tolerating the loss of several drives. That requires several machines to be meaningful; erasure coding across directories on one disk protects against nothing.
Whatever your redundancy story, keep a copy elsewhere. Mirroring is one command and can run from cron.
mc mirror --overwrite --remove local/myapp-media remote/myapp-media-backup
Think carefully about --remove. It makes the mirror an exact copy, which means a
deletion — accidental or malicious — propagates to your backup on the next run. For a genuine backup,
omit it and rely on lifecycle rules to control growth, so that deleting an object locally does not
erase your only other copy.
Versioning keeps previous copies when an object is overwritten or deleted, which turns "someone overwrote the wrong file" from an incident into a two-minute recovery.
mc version enable local/myapp-media
mc ilm rule add --expire-delete-marker --noncurrent-expire-days 90 local/myapp-media
Object lock goes further, making objects immutable for a retention period — even the account that wrote them cannot delete them early. That is the property that makes a backup survive a compromise of the system it is backing up.
Uploads accumulate. Temporary exports, thumbnails, abandoned drafts and old versions all persist unless something removes them.
Express retention as a rule on the bucket rather than as a management command you will forget to run. Expire temporary prefixes after days, non-current versions after months, and incomplete multipart uploads after a week — that last one silently consumes real space and appears in no listing.
If the store is only reachable on an internal network, plain HTTP between application and storage is defensible. The moment browsers fetch objects directly, or the store is reachable across a network you do not fully control, it needs TLS — those requests carry signed URLs, and a signed URL is a credential.
Terminate TLS at your reverse proxy and route to the store internally. That reuses the certificate handling you already have rather than configuring certificates twice.
Object storage is optimised for whole-object reads and writes, not for partial updates. Listing is comparatively expensive and gets slower as a prefix grows, so avoid designs that list a directory of millions of keys on every page load — keep the index in your database and use the store only to fetch by key.
Many small objects are also less efficient than fewer large ones, since each carries per-object overhead. For thumbnails and similar, that is usually acceptable; for logging individual events as objects, it is not.
Self-hosting makes sense when you need data in a specific jurisdiction, when egress charges dominate your bill, when you are already operating servers competently, or when the store is part of the product you sell.
A managed provider wins when durability matters more than control, when nobody on the team wants to be responsible for a storage system at three in the morning, or when the volume is small enough that the managed cost is trivial. Both answers are defensible; what is not defensible is running a single node and describing it as highly available.
The portability argument only pays off if you have actually rehearsed it. The good news is that a migration is mostly a copy followed by a configuration change.
mc alias set old https://old-endpoint.example ACCESS SECRET
mc alias set new https://new-endpoint.example ACCESS SECRET
mc mirror --preserve old/myapp-media new/myapp-media
mc ls --recursive old/myapp-media | wc -l
mc ls --recursive new/myapp-media | wc -l
Copy while the application still writes to the old store, then run the mirror a second time immediately before switching the endpoint — the second pass moves only what changed and takes seconds. Compare object counts on both sides before you cut over, and keep the old store intact for a few days so a rollback is a configuration change rather than a restore.
Because Django stores only the key in the database, none of this touches your data. The path
uploads/2026/invoice.pdf means the same thing to every provider, which is precisely why
the abstraction is worth respecting: resist the temptation to store full URLs in your models, or you
will rewrite every row during a migration.
Running storage yourself means you own its failure modes. Three things are worth watching, and none of them are visible from the application.
Disk headroom first: an object store that fills up returns errors on write, which surfaces in your application as failed uploads with an unhelpful message. Alert at seventy percent, not ninety — freeing space in an object store means deciding what to delete, and that takes longer than you think.
Then the mirror. A backup job that has not run for three weeks is the normal way this goes wrong, so alert on the absence of a successful run rather than on failures alone.
df -h /var/lib/storage | awk 'NR==2 {print $5" used"}'
mc admin info local
find /var/log/mirror.log -mmin -1440 -size +0 || echo "MIRROR DID NOT RUN"
And finally latency from the application's perspective. A store that has become slow — a failing disk, a saturated link — shows up as pages that take seconds to render an image, long before anything reports an actual error. Measuring from the application catches the degradation that a health check on the store itself will happily report as fine.
The S3 API is the portability layer — design against it and switching providers stays cheap. Run the store on an internal network, never expose it directly, and use per-application credentials scoped to a single bucket rather than root. Keep user content private with short-lived signed URLs and public assets in a separate public bucket. Sign uploads server-side with keys you generate. Be honest that a single node has single-disk durability, mirror elsewhere without propagating deletions, and use versioning and object lock where the data matters. Express retention as lifecycle rules. And choose managed storage without embarrassment when durability matters more than control.