# Storage and CDN

By default koja-mdt keeps bodycam footage in `recordings/` inside the resource folder and stores pictures as data URLs in the database. On a busy server that fills the disk and the database quickly. `Config.Storage` moves both to object storage:

| Driver | Footage | Images | Works with |
|---|---|---|---|
| `local` | Server disk | Inline in the database | Nothing to set up |
| `s3` | Bucket, presigned or public links | Public bucket URL | Cloudflare R2, AWS S3, Backblaze B2, MinIO and other S3-compatible services |
| `fivemanage` | Fivemanage files | Fivemanage URL | Fivemanage API key |

## How it works

- **Footage segments.** Frames from the wearer's client are packed into one segment file per `segmentSeconds` (10 seconds, about 80 frames at 8 fps). A recording hour is 360 uploads instead of 28,800. Segments are always written to disk first, so the local driver and the remote drivers share one code path.
- **Uploads.** With `footage = 'storage'` every finished segment is queued for upload. When a session stops, the partial segment is flushed too. Uploads retry with backoff. If the bucket is down, segments wait on disk and are uploaded later, also after a restart. The local copy is removed only after the upload is recorded in the database.
- **Playback.** The player downloads one segment at a time, cuts it into frames in the browser, keeps a few segments around the playhead and prefetches the next one. Remote segments use short-lived presigned links (`presignMinutes`) or your `publicUrl`. Local segments use the token protected `/recordings/<session>/s/<n>` route.
- **Images.** With `images = 'storage'` the MDT sends the compressed picture to the server, the server uploads it and only the public URL is saved. API keys never reach players.
- **Retention.** `keepDays`, `maxDiskMB` and manual deletes also delete the remote objects. S3 uses batched `DeleteObjects`, Fivemanage deletes each file by its id. Pending deletes survive restarts.

:::hint{type="warning" title="Secrets"}
Access keys go in `server.cfg` with `set` (or in `editable/server/storage.lua`). Never put them in `shared/config.lua`: that file is downloaded by every player.
:::

## Cloudflare R2

:::steps
### Create a bucket

In the Cloudflare dashboard open R2 Object Storage and create a bucket, for example `koja-mdt`.

### Create an API token

Open Manage R2 API Tokens, create a token with the Object Read & Write permission and limit it to your bucket. Copy the Access Key ID and the Secret Access Key.

### Find the S3 endpoint

The endpoint is `https://<accountid>.r2.cloudflarestorage.com`. The account id is shown on the R2 overview page.

### Make images public

Images need a public address. In the bucket settings enable the r2.dev subdomain, or better, connect a custom domain such as `cdn.example.com`. Put that address in `publicUrl`.

### Add the CORS policy

In the bucket settings open CORS Policy and paste the policy from the CORS section below.

### Configure koja-mdt

```lua title="shared/config.lua"
Config.Storage = {
    driver = 's3',
    images = 'storage',
    footage = 'storage',
    segmentSeconds = 10,
    concurrency = 3,
    maxBacklog = 400,
    s3 = {
        endpoint = 'https://<accountid>.r2.cloudflarestorage.com',
        region = 'auto',
        bucket = 'koja-mdt',
        publicUrl = 'https://cdn.example.com',
        pathStyle = true,
        presignMinutes = 10,
        prefix = 'koja-mdt/',
    },
    fivemanage = {
        path = 'koja-mdt',
    },
}
```

```cfg title="server.cfg"
set koja_mdt_s3_access_key "your-access-key-id"
set koja_mdt_s3_secret_key "your-secret-access-key"
```
:::

## AWS S3

:::steps
### Create a bucket

Create a bucket in the region of your server. Keep Block Public Access on if you only store footage. For images, allow public reads on the `koja-mdt/images/` prefix with a bucket policy, or put CloudFront in front of the bucket.

### Create an IAM user

Give it `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject` and `s3:ListBucket` on the bucket, then create an access key.

### Configure koja-mdt

Use `endpoint = 'https://s3.<region>.amazonaws.com'`, `region = '<region>'` (for example `'eu-central-1'`) and `pathStyle = false`. Set `publicUrl` to the bucket website, CloudFront or custom domain address when you use `images = 'storage'`.
:::

## Backblaze B2

:::steps
### Create a bucket

Create a bucket. Make it public if you want images served from it.

### Create an application key

Create an application key with read and write access to the bucket. The key ID is the access key, the application key is the secret.

### Configure koja-mdt

Use the S3 endpoint shown on the bucket page, for example `https://s3.eu-central-003.backblazeb2.com`, with `region = 'eu-central-003'`. For public images set `publicUrl` to `https://f003.backblazeb2.com/file/<bucket>` or your CDN domain.
:::

## MinIO

:::steps
### Create a bucket and a user

Create a bucket and an access key with read and write access to it.

### Configure koja-mdt

Use your MinIO address as the endpoint, for example `https://minio.example.com` or `http://10.0.0.5:9000`, keep `pathStyle = true` and set `region` to the MinIO region (`'us-east-1'` unless you changed it). For images, allow anonymous downloads on the images prefix and set `publicUrl` to the public bucket address.
:::

## Fivemanage

:::steps
### Create an API key

In the Fivemanage dashboard create an API key for file uploads.

### Configure koja-mdt

```lua title="shared/config.lua"
Config.Storage = {
    driver = 'fivemanage',
    images = 'storage',
    footage = 'storage',
    segmentSeconds = 10,
    fivemanage = {
        path = 'koja-mdt',
    },
}
```

```cfg title="server.cfg"
set koja_mdt_fivemanage_key "your-fivemanage-api-key"
```
:::

Files are uploaded to `https://api.fivemanage.com/api/v3/file` and deleted by their id. Playback uses the URL Fivemanage returns, so no presigning is needed.

## CORS

The player downloads segments with `fetch`, so the bucket must allow `GET` from the MDT interface (`https://cfx-nui-koja-mdt`) and from the web portal. Replace the last origin with the address your officers open the portal on:

```json title="CORS policy"
[
  {
    "AllowedOrigins": ["https://cfx-nui-koja-mdt", "nui://koja-mdt", "https://mdt.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["Content-Length", "ETag"],
    "MaxAgeSeconds": 3600
  }
]
```

R2, AWS S3 and B2 accept this JSON in the bucket CORS settings. On MinIO set the same origins with `mc admin config set` or a reverse proxy. If a provider rejects the `nui://` origin, remove it: the in-game MDT uses `https://cfx-nui-koja-mdt`.

## Checking that it works

After a restart the startup banner shows a line like:

```text title="console"
Storage s3 · footage remote · images storage · HeadBucket ok (84ms)
```

A failed self-test is printed in red with the reason (wrong key, wrong region, missing bucket). Missing options are printed as warnings and the MDT keeps using local disk and inline images until they are fixed. `exports['koja-mdt']:GetStorageStatus()` returns the same information plus the number of queued uploads.

## Costs

Assumptions: frames are about 12 to 15 KB at the default 560 px width and quality 0.42, so 8 fps is about 400 MB per recording hour. Segments of 10 seconds turn that into 360 writes per hour. The table shows 100 recording hours per month (about 40 GB stored, 36,000 writes) at list prices. Check the provider for current prices and free tiers.

| Provider | Storage | Writes | Downloads | About per month |
|---|---|---|---|---|
| Cloudflare R2 | $0.015 per GB, 10 GB free | $4.50 per million, 1 million free | Egress free | $0.45 to $0.80 |
| AWS S3 Standard | $0.023 per GB | $0.005 per 1,000 | $0.09 per GB after the free egress allowance | $1.10 plus egress |
| Backblaze B2 | $0.006 per GB | Free | Free up to 3x your stored data | $0.25 |
| MinIO | Your own disk | Free | Your own bandwidth | Hardware only |
| Fivemanage | Included in the plan | Included in the plan | Included in the plan | See your plan limits |

Without segments the same 100 hours would be 2.88 million writes, about $13 on R2 and $14 on S3 for writes alone.
