---
name: s3-django
description: "Provisions an S3 bucket with keys scoped to it for a Django app with `npx extraorbital add s3` (no signup, no card) and wires it into Django with `boto3`. Use when a Django project needs S3 storage, object storage, file uploads, a bucket, blob storage, R2, B2 or S3_BUCKET, or when S3_BUCKET is missing or unset in .env."
---

# S3 storage in Django

<!-- Generated from ExtraOrbital's service registry by `pnpm skills:build` in platform/app. Edit the generator, not this file. -->

ExtraOrbital provisions an S3 bucket with keys scoped to it in one command and writes the credentials into the project's env file. For anything this recipe does not cover, use the `extraorbital` skill or https://extraorbital.dev/llms.txt.

## 1. Provision

```bash
npx extraorbital add s3
```

Run it from the project root. It is idempotent (a second run returns the same resource) and writes to `.env.local` if the project has one, otherwise `.env`, keeping that file out of git. Add `--json` to parse the result; credential values are redacted there, and you should never print them either.

| Variable | |
| --- | --- |
| `S3_BUCKET` |  |
| `S3_ENDPOINT` |  |
| `S3_REGION` |  |
| `S3_ACCESS_KEY_ID` |  |
| `S3_SECRET_ACCESS_KEY` |  |

Free on the Prototype plan: 1 GB, 10K simple + 2K advanced ops/mo.

## 2. Install the client

```bash
pip install python-dotenv boto3
```

## 3. Connect

Django reads no env file by itself: `settings.py` loads it with `python-dotenv`, below `BASE_DIR`.

**`yourproject/settings.py`**

```python
import os

from dotenv import load_dotenv

# ExtraOrbital writes .env.local when the project has one, else .env.
# load_dotenv never overrides a variable already set, so the first file wins.
load_dotenv(BASE_DIR / ".env.local")
load_dotenv(BASE_DIR / ".env")

S3_BUCKET = os.environ["S3_BUCKET"]
S3_ENDPOINT = os.environ["S3_ENDPOINT"]
S3_REGION = os.environ["S3_REGION"]
S3_ACCESS_KEY_ID = os.environ["S3_ACCESS_KEY_ID"]
S3_SECRET_ACCESS_KEY = os.environ["S3_SECRET_ACCESS_KEY"]
```

**`yourapp/storage.py`**

```python
import boto3
from botocore.config import Config

from django.conf import settings

s3 = boto3.client(
    "s3",
    endpoint_url=settings.S3_ENDPOINT,
    region_name=settings.S3_REGION,
    aws_access_key_id=settings.S3_ACCESS_KEY_ID,
    aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
    # Path-style addressing works on AWS and on S3-compatible endpoints alike.
    config=Config(s3={"addressing_style": "path"}),
)
BUCKET = settings.S3_BUCKET
```

## 4. Use it

**`yourapp/views.py`**

```python
from django.http import JsonResponse

from yourapp.storage import BUCKET, s3

# yourapp/urls.py: path("api/s3/", views.check_s3)


def check_s3(request):
    s3.put_object(Bucket=BUCKET, Key="hello.txt", Body=b"hi")
    listed = s3.list_objects_v2(Bucket=BUCKET, MaxKeys=10)
    return JsonResponse({"keys": [o["Key"] for o in listed.get("Contents", [])]})
```

## 5. Verify

```bash
npx extraorbital list        # the resource is listed as active
python manage.py runserver
curl http://localhost:8000/api/s3/
```

A JSON answer means the credentials, the client and the route all work. Delete the route afterwards if the app does not need it.

## S3-compatible alternatives

Same client, same code; only the variable names differ. Pick one when the project has a reason to:

- `npx extraorbital add r2` — Cloudflare R2: no egress fees; region `auto`; on the free plan files expire 30 days after upload. Writes `R2_BUCKET`, `R2_ENDPOINT`, `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`.
- `npx extraorbital add b2` — Backblaze B2: cheaper per stored byte. Writes `B2_BUCKET`, `B2_ENDPOINT`, `B2_REGION`, `B2_ACCESS_KEY_ID`, `B2_SECRET_ACCESS_KEY`.

## Pitfalls

- Objects are private unless the bucket was created with `--public` (`npx extraorbital add s3 --public`); options apply at creation only.
- For browser uploads, hand out a presigned URL (`s3.generate_presigned_url`) rather than the keys.
- The keys reach this bucket only: listing buckets or creating new ones is refused.
- `yourapp` and `yourproject` stand for the project's own app and settings package.
- Read credentials once in `settings.py` and import `django.conf.settings` elsewhere; `os.environ[...]` fails at startup when one is missing, which is the point.
- Exit code 3 means the account is past its free allowance: show the human the link the command printed and wait. Do not retry or work around it.

Docs: https://extraorbital.dev/docs/resources/s3.md
