---
name: s3-fastapi
description: "Provisions an S3 bucket with keys scoped to it for a FastAPI app with `npx extraorbital add s3` (no signup, no card) and wires it into FastAPI with `boto3`. Use when a FastAPI 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 FastAPI

<!-- 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 pydantic-settings boto3
```

## 3. Connect

`pydantic-settings` reads `.env` and `.env.local` itself; if `app/config.py` already exists, add the fields to its `Settings`.

**`app/config.py`**

```python
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    # ExtraOrbital writes .env.local when the project has one, else .env; the later file wins.
    model_config = SettingsConfigDict(env_file=(".env", ".env.local"), extra="ignore")

    S3_BUCKET: str
    S3_ENDPOINT: str
    S3_REGION: str
    S3_ACCESS_KEY_ID: str
    S3_SECRET_ACCESS_KEY: str


settings = Settings()
```

**`app/storage.py`**

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

from app.config 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

**`app/api/s3.py`**

```python
from fastapi import APIRouter

from app.storage import BUCKET, s3

router = APIRouter()
# In app/main.py: from app.api import s3; app.include_router(s3.router)


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

## 5. Verify

```bash
npx extraorbital list        # the resource is listed as active
fastapi dev app/main.py
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.
- A blocking client (`boto3`, `stripe`) belongs in a plain `def` route, which FastAPI runs in a threadpool; in `async def` it stalls every other request.
- With `uv`, `uv add` the same packages instead of `pip install`.
- 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
