Koja Scripts

Storage and CDN

Keep bodycam footage and MDT images on Cloudflare R2, AWS S3, Backblaze B2, MinIO or Fivemanage instead of the server disk, with setup steps, CORS and costs.

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:

DriverFootageImagesWorks with
localServer diskInline in the databaseNothing to set up
s3Bucket, presigned or public linksPublic bucket URLCloudflare R2, AWS S3, Backblaze B2, MinIO and other S3-compatible services
fivemanageFivemanage filesFivemanage URLFivemanage 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.

Cloudflare R2

  1. Create a bucket

    In the Cloudflare dashboard open R2 Object Storage and create a bucket, for example koja-mdt.
  2. 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.
  3. Find the S3 endpoint

    The endpoint is https://<accountid>.r2.cloudflarestorage.com. The account id is shown on the R2 overview page.
  4. 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.
  5. Add the CORS policy

    In the bucket settings open CORS Policy and paste the policy from the CORS section below.
  6. Configure koja-mdt

    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',
        },
    }
    server.cfg
    set koja_mdt_s3_access_key "your-access-key-id"
    set koja_mdt_s3_secret_key "your-secret-access-key"

AWS S3

  1. 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.
  2. Create an IAM user

    Give it s3:PutObject, s3:GetObject, s3:DeleteObject and s3:ListBucket on the bucket, then create an access key.
  3. 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

  1. Create a bucket

    Create a bucket. Make it public if you want images served from it.
  2. 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.
  3. 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

  1. Create a bucket and a user

    Create a bucket and an access key with read and write access to it.
  2. 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

  1. Create an API key

    In the Fivemanage dashboard create an API key for file uploads.
  2. Configure koja-mdt

    shared/config.lua
    Config.Storage = {
        driver = 'fivemanage',
        images = 'storage',
        footage = 'storage',
        segmentSeconds = 10,
        fivemanage = {
            path = 'koja-mdt',
        },
    }
    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:

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:

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.

ProviderStorageWritesDownloadsAbout per month
Cloudflare R2$0.015 per GB, 10 GB free$4.50 per million, 1 million freeEgress 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 GBFreeFree up to 3x your stored data$0.25
MinIOYour own diskFreeYour own bandwidthHardware only
FivemanageIncluded in the planIncluded in the planIncluded in the planSee 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.