Object Storage (AWS S3 + MinIO)
Media uploads (avatars, future user-generated content) go through django-storages with an S3-compatible backend. Production runs against AWS S3 (or another S3-compatible service); local development runs against MinIO in Docker. The same backend class is used in both environments.
There are two storage classes in apps/base/storage.py:
| Backend | When to use |
|---|---|
apps.base.storage.S3MediaStorage |
Default for both local dev and production. Adds an optional URL-rewrite step for the Docker-internal vs. browser endpoint split MinIO needs. |
apps.base.storage.MediaS3Storage |
Optional. Adds ManifestFilesMixin so static files are uploaded to S3 with hashed filenames. Use only if you'd rather serve static from S3 instead of letting WhiteNoise serve them from the app. |
Static files (Vite-hashed JS/CSS) are served by WhiteNoise out of the gunicorn process with Cache-Control: max-age=31536000, immutable. Putting a CDN like CloudFront in front of the app gives you the same low-latency story as serving from S3, with fewer moving parts. MediaS3Storage is here for projects that prefer the older static-on-S3 pattern.
Production: AWS S3
1. Create an IAM user and bucket
- In the AWS Console, create an IAM user with programmatic access. Save the access key and secret.
- Create an S3 bucket. If you'll point a CNAME at it, use a fully qualified domain name as the bucket name.
- Attach a policy like the following to the user (replace the bucket name):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::your-bucket-name"
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}
- If your media URLs will be loaded cross-origin (e.g. avatar served on a different subdomain), add a CORS configuration:
[
{
"AllowedHeaders": ["Authorization"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedOrigins": ["https://your-app.example.com"],
"MaxAgeSeconds": 3000
}
]
2. Settings
Set the following in your production environment (don't set MEDIA_S3_URL_ENDPOINT_URL — that's only for the local MinIO endpoint split):
DEFAULT_FILE_STORAGE=apps.base.storage.S3MediaStorage
MEDIA_S3_ACCESS_KEY=<access key id>
MEDIA_S3_SECRET_KEY=<secret access key>
MEDIA_S3_BUCKET_NAME=<bucket name>
# MEDIA_S3_ENDPOINT_URL is optional for AWS - the boto3 client picks the right
# regional endpoint automatically. Set it explicitly if you're using a custom
# endpoint (VPC endpoint, S3-compatible service, etc.).
Avatars are uploaded with default_acl=private and querystring_auth=True, so generated URLs are signed and time-limited.
Optional: serve static files from S3 too
If you'd rather serve static files from S3 with hashed filenames instead of via WhiteNoise, switch to MediaS3Storage + StaticS3Storage:
DEFAULT_FILE_STORAGE=apps.base.storage.MediaS3Storage
STATICFILES_STORAGE=apps.base.storage.StaticS3Storage
AWS_ACCESS_KEY_ID=<key>
AWS_SECRET_ACCESS_KEY=<secret>
AWS_STORAGE_BUCKET_NAME=<bucket>
AWS_S3_REGION=us-east-2
After each static-file change, run ./manage.py collectstatic to upload to S3.
Local Development: MinIO
Nothing to configure — compose.yml runs MinIO and just create_env writes sensible defaults to .env:
DEFAULT_FILE_STORAGE=apps.base.storage.S3MediaStorage
MEDIA_S3_ACCESS_KEY=minioadmin
MEDIA_S3_SECRET_KEY=minioadmin
MEDIA_S3_ENDPOINT_URL=http://minio:9000
MEDIA_S3_URL_ENDPOINT_URL=http://localhost:9000
MEDIA_S3_BUCKET_NAME=media
The two endpoint variables are deliberately different:
MEDIA_S3_ENDPOINT_URL— what Django uses to talk to MinIO. Inside the Docker network, MinIO is reachable athttp://minio:9000.MEDIA_S3_URL_ENDPOINT_URL— what ends up in URLs the browser will fetch. The browser doesn't know about the Docker network, so URLs need to point athttp://localhost:9000.
S3MediaStorage rewrites the host substring at URL-generation time so generated avatar URLs work in the browser. In production against AWS, leave MEDIA_S3_URL_ENDPOINT_URL unset — the URL rewrite is skipped.
The web container runs ./manage.py ensure_s3_bucket on startup, which creates the bucket if it doesn't exist. The MinIO console is at http://localhost:9001 (creds default to minioadmin / minioadmin).