Self-Host Django Media Storage With MinIO

Run MinIO as self-hosted S3-compatible storage for Django uploads: deploy it with Docker Compose or systemd, create a bucket and scoped user, configure django-storages, put MinIO behind Nginx with TLS, and back it up.

Problem statement

Some Django deployments cannot or should not use public cloud object storage: on-premises installations, data residency requirements, air-gapped networks, or simply wanting the same S3 code path in development as in production. MinIO is an S3-compatible object store you run yourself. Django talks to it through django-storages' S3 backend, so application code does not change between MinIO and S3. Self-hosting storage, though, makes you responsible for TLS, access control, disks, and backups.

Self-hosted media with MinIODjango uploadS3 backendMinIO serverS3 API, port 9000Disk volumeerasure coded drivesBackupsmirror off-siteMinIO gives Django the S3 API on your own servers; durability is your responsibility.
  1. Run MinIO with a persistent volume (Docker Compose or systemd), with root credentials from secrets.
  2. Create a bucket and a dedicated user with a policy limited to that bucket (mc admin user add, mc admin policy attach).
  3. Configure S3Storage with endpoint_url pointing at MinIO, the app user's keys, and path-style addressing.
  4. Put MinIO behind Nginx with TLS; keep the console (port 9001) private.
  5. Set custom_domain or a public endpoint so presigned URLs use a hostname browsers can reach.
  6. Mirror the bucket to another location regularly; a single-node MinIO on one disk is not durable.

Step-by-step solution

1. Run MinIO

services:
  minio:
    image: minio/minio:latest   # pin a specific release tag in production
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER_FILE: /run/secrets/minio_root_user
      MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio_root_password
      MINIO_SERVER_URL: https://media.example.com
    volumes:
      - minio-data:/data
    secrets: [minio_root_user, minio_root_password]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 15s
volumes:
  minio-data:

MINIO_SERVER_URL tells MinIO its public URL, which matters for presigned URLs. Pin a release tag rather than latest, and read release notes before upgrading.

2. Bucket and least-privilege user

mc alias set local http://localhost:9000 "$ROOT_USER" "$ROOT_PASSWORD"
mc mb local/django-media
cat > django-media-rw.json <<'JSON'
{ "Version": "2012-10-17", "Statement": [{
  "Effect": "Allow",
  "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
  "Resource": ["arn:aws:s3:::django-media", "arn:aws:s3:::django-media/*"] }] }
JSON
mc admin policy create local django-media-rw django-media-rw.json
mc admin user add local django-app "$APP_SECRET"
mc admin policy attach local django-media-rw --user django-app

The app never uses the root credentials.

what you operate when you self-host storageNginx with TLSmedia.example.com, large bodiesMinIO S3 API :9000console :9001 kept privateBucket policy + app userleast privilegeDisks / volumeserasure coding on 4+ drivesOff-site mirrormc mirror or replicationEvery layer that a cloud provider would run is now yoursdjango-deployment.com
A single-disk MinIO is a convenience, not durable storage; plan backups first.

3. Configure Django

STORAGES = {
    "default": {
        "BACKEND": "storages.backends.s3.S3Storage",
        "OPTIONS": {
            "bucket_name": "django-media",
            "endpoint_url": env("MINIO_ENDPOINT", default="http://minio:9000"),
            "access_key": env("MINIO_ACCESS_KEY"),
            "secret_key": env("MINIO_SECRET_KEY"),
            "region_name": "us-east-1",
            "addressing_style": "path",
            "querystring_auth": True,
            "file_overwrite": False,
            "custom_domain": None,
        },
    },
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

Path-style addressing (http://minio:9000/django-media/key) avoids needing wildcard DNS for bucket subdomains. One subtlety: presigned URLs are signed for the host in endpoint_url. If Django reaches MinIO internally at http://minio:9000 but browsers must use https://media.example.com, the signatures will not match. Point endpoint_url at the public URL, or use django-storages' separate URL settings for your version so signing uses the public host.

4. TLS and Nginx

server {
    listen 443 ssl;
    server_name media.example.com;
    client_max_body_size 0;          # allow large uploads
    proxy_buffering off;
    proxy_request_buffering off;
    location / {
        proxy_pass http://127.0.0.1:9000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Forward the original Host header unchanged; S3 signatures include it.

5. Durability and backups

Single-node, single-drive MinIO stores one copy of each object. For durability, run MinIO in distributed mode with erasure coding across four or more drives (and ideally several nodes), and mirror buckets off-site:

mc mirror --overwrite --remove local/django-media offsite/django-media

Run it from a timer and alert on failures.

MinIO for Django in productionPinned MinIO release, persistent volumeupgrade deliberatelyApp user with bucket-only policynever root keysTLS via Nginx, Host header preservedsignatures stay validPresigned URLs use the public hostnot the internal nameErasure coding or off-site mirrorone disk is one copySelf-hosted storage needs the same care as a self-hosted databasedjango-deployment.com
Test a restore from the mirror before relying on it.

Verification check: upload a file through Django and see it with mc ls local/django-media; open the generated link in a browser from outside the server (it must use media.example.com and load); run the mirror job and confirm the object exists at the off-site target.

Explanation

MinIO implements the S3 API, including SigV4 signatures, presigned URLs, bucket policies, and multipart uploads, so S3 client libraries work by changing the endpoint. That makes it ideal for keeping one storage code path across development, on-premises, and cloud deployments. The trade-off is operational: durability comes from erasure coding across drives and from backups you run, and the public hostname used for signing must match what browsers request, which is where most self-hosted setups trip up.

Edge cases / notes

  • Licensing. MinIO is AGPLv3; review the license implications for your use, and check the current distribution of features between the open-source and commercial editions.
  • Development. Running MinIO in Docker Compose locally mirrors production S3 behaviour closely.
  • Alternatives include Garage and SeaweedFS for self-hosted S3-compatible storage.
  • Disk full stops writes; monitor free space and set alerts.

Next steps

FAQ

Can Django use MinIO as S3?

Yes. Configure django-storages' S3 backend with MinIO's endpoint and keys.

Why do presigned MinIO URLs fail in the browser?

They were signed for an internal hostname. Sign with the public URL that browsers use.

Is a single MinIO node safe for production?

Only with reliable backups. Use erasure coding across several drives for durability.

Should the app use MinIO root credentials?

No. Create a user with a policy limited to the app's bucket.

Keep going