---
title: Storage
description: Configure the two S3-compatible buckets that hold avatars, organization logos, and chat attachments, including the CORS rule both buckets need.
---

Cargo Marshal stores uploaded images in two S3-compatible buckets. This page is for operators who self-host or deploy the app. It explains what each bucket holds and gives the CORS rule both buckets need.

## The public images bucket

The public bucket holds avatars and organization logos. Anyone with the URL can read an object. The app serves these files straight from `NEXT_PUBLIC_S3_PUBLIC_URL`, and the bucket name is `NEXT_PUBLIC_S3_BUCKET_NAME_IMAGES`. If you leave both unset, avatar and logo uploads stay visible but disabled.

## The private attachments bucket

The private bucket holds chat image attachments. It blocks all public access and has its own name in `S3_BUCKET_NAME_ATTACHMENTS`. The app checks that the viewer can read the chat, then hands the browser a signed GET URL that expires after about 30 minutes. If you leave the variable unset, chat image uploads are disabled. Never point it at the public images bucket.

Both buckets use the same credentials (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`) and the same endpoint (`AWS_ENDPOINT_URL_S3`). With the endpoint unset, the app talks to AWS S3 in `AWS_REGION`. Set `AWS_S3_FORCE_PATH_STYLE=true` if your provider needs path-style URLs.

## Required Bucket Configuration

The browser uploads each file with a presigned PUT request straight to the bucket, and it loads chat attachments with a signed GET request. Both are cross-origin requests, so each bucket must answer the CORS preflight. A bucket without a CORS rule rejects the preflight, and the upload fails. R2 answers the preflight with `403`.

Apply the same rule to both buckets. List every origin that serves the app:

- The production URL, with and without `www` if both resolve.
- Every preview deployment URL, for example `https://cargomarshal-git-my-branch.vercel.app`.
- `http://localhost:3000` for local work.

An origin is the scheme, host, and port, with no path and no trailing slash.

### Cloudflare R2

In the Cloudflare dashboard, open the bucket, go to **Settings**, and add a CORS policy with this JSON. You can also send it to the R2 CORS API.

```json
{
  "rules": [
    {
      "allowed": {
        "origins": [
          "https://www.cargomarshal.com",
          "https://cargomarshal.com",
          "http://localhost:3000"
        ],
        "methods": ["GET", "HEAD", "PUT"],
        "headers": ["*"]
      },
      "exposeHeaders": ["ETag"],
      "maxAgeSeconds": 3600
    }
  ]
}
```

### AWS S3 and other S3-compatible providers

Send this JSON to `PutBucketCors`, for example with `aws s3api put-bucket-cors --bucket <name> --cors-configuration file://cors.json`.

```json
{
  "CORSRules": [
    {
      "AllowedOrigins": [
        "https://www.cargomarshal.com",
        "https://cargomarshal.com",
        "http://localhost:3000"
      ],
      "AllowedMethods": ["GET", "HEAD", "PUT"],
      "AllowedHeaders": ["*"],
      "ExposeHeaders": ["ETag"],
      "MaxAgeSeconds": 3600
    }
  ]
}
```

### Generate and check the rule

Run `pnpm storage:cors` in the repository root. It reads `apps/web/.env`, or `apps/web/.env.test` when the first file is missing, and prints both JSON forms for each configured bucket. Pass one `--origin` flag per app origin. Without a flag, the command uses the origin of `NEXT_PUBLIC_APP_URL`.

```bash
pnpm storage:cors --origin https://cargomarshal-git-my-branch.vercel.app
```

To test the live buckets, add `--check`. The command sends a read-only preflight (an HTTP `OPTIONS` request, with no credentials and no write) for each origin. It tests GET on the public URL, PUT on the images bucket, and PUT and GET on the private bucket. It prints one PASS or FAIL line per probe with the reason, and exits with code 1 if any probe fails.

```bash
pnpm storage:cors --check --origin https://cargomarshal-git-my-branch.vercel.app
```

## The Loading Report and the logo

The Loading Report first asks the app's own image endpoint, `/_next/image`, for an uploaded PNG or JPEG logo. That endpoint loads the file on the server, so this request is same-origin and does not depend on the bucket's CORS rule.

In these cases the report fetches the logo straight from its URL instead, and a logo in the public bucket then needs GET in the rule above:

- The logo is an SVG, or the organization has no logo and uses its generated DiceBear mark. `/_next/image` does not serve SVG.
- The bucket runs on a private address, such as a local MinIO on `localhost`. Next.js refuses to load images from private addresses, so `/_next/image` fails.
- `/_next/image` fails for any other reason, for example when the bucket host is missing from the image allowlist.

If the direct request fails too, the report prints without the logo. Keep GET in the rule for both buckets. Uploads also fail without the PUT rule, and chat attachments need GET on the private bucket.
