Skip to content

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

  1. In the AWS Console, create an IAM user with programmatic access. Save the access key and secret.
  2. Create an S3 bucket. If you'll point a CNAME at it, use a fully qualified domain name as the bucket name.
  3. 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/*"
        }
    ]
}
  1. 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 at http://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 at http://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).