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 yourpublicUrl. 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,maxDiskMBand manual deletes also delete the remote objects. S3 uses batchedDeleteObjects, Fivemanage deletes each file by its id. Pending deletes survive restarts.
Cloudflare R2
-
Create a bucket
In the Cloudflare dashboard open R2 Object Storage and create a bucket, for examplekoja-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 ishttps://<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 ascdn.example.com. Put that address inpublicUrl. -
Add the CORS policy
In the bucket settings open CORS Policy and paste the policy from the CORS section below. -
Configure koja-mdt
shared/config.luaConfig.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', }, }server.cfgset koja_mdt_s3_access_key "your-access-key-id" set koja_mdt_s3_secret_key "your-secret-access-key"
AWS S3
-
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 thekoja-mdt/images/prefix with a bucket policy, or put CloudFront in front of the bucket. -
Create an IAM user
Give its3:PutObject,s3:GetObject,s3:DeleteObjectands3:ListBucketon the bucket, then create an access key. -
Configure koja-mdt
Useendpoint = 'https://s3.<region>.amazonaws.com',region = '<region>'(for example'eu-central-1') andpathStyle = false. SetpublicUrlto the bucket website, CloudFront or custom domain address when you useimages = 'storage'.
Backblaze B2
-
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 examplehttps://s3.eu-central-003.backblazeb2.com, withregion = 'eu-central-003'. For public images setpublicUrltohttps://f003.backblazeb2.com/file/<bucket>or your CDN domain.
MinIO
-
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 examplehttps://minio.example.comorhttp://10.0.0.5:9000, keeppathStyle = trueand setregionto the MinIO region ('us-east-1'unless you changed it). For images, allow anonymous downloads on the images prefix and setpublicUrlto the public bucket address.
Fivemanage
-
Create an API key
In the Fivemanage dashboard create an API key for file uploads. -
Configure koja-mdt
shared/config.luaConfig.Storage = { driver = 'fivemanage', images = 'storage', footage = 'storage', segmentSeconds = 10, fivemanage = { path = 'koja-mdt', }, }server.cfgset 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:
[
{
"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:
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.