Live streams

Historian: your own storage

Keep recordings in your own S3-compatible bucket (AWS, R2, B2, Wasabi, Hetzner, OVH, or MinIO on site): key, CORS, lifecycle, setup and health.

By default the recordings of sources that record always or by trigger are stored by Kymoscope. An organization can instead keep their day files in its own S3-compatible bucket, in the cloud or on site. This page is for the IT administrator who prepares the bucket and the organization admin who connects it in the app. Nothing changes for the people who chart history: the same days open the same way.

When to use it

  • Data sovereignty: the files stay in a bucket, a region or a building you choose.
  • Your own retention rules or a write-once (WORM) policy on top of ours.
  • Very long histories on storage you already pay for.

Day files are encrypted on the collector before they leave it, so your bucket holds only ciphertext, exactly as Kymoscope storage does. Kymoscope keeps only the index: which day, which file, which version. Your own storage is part of Enterprise; ask Kymoscope to turn it on for your organization.

What you need

An S3-compatible bucket reachable over HTTPS, with a certificate the browsers trust, from two places: your collectors (they upload) and the browsers of the people who open history (they read day files straight from the bucket). Kymoscope itself never connects to it.

ProviderEndpoint and region
AWS S3https://s3.<region>.amazonaws.com, region for example eu-central-1
Cloudflare R2https://<account>.eu.r2.cloudflarestorage.com, region auto
Backblaze B2https://s3.<region>.backblazeb2.com, region for example eu-central-003
Wasabihttps://s3.<region>.wasabisys.com, region for example eu-central-1
Hetzner Object Storagehttps://<location>.your-objectstorage.com, region = the location, for example fsn1
OVHcloudhttps://s3.<region>.io.cloud.ovh.net, region for example gra
On site: MinIO, Garage, SeaweedFS or Ceph RGWhttps://minio.customer.local:9000, region us-east-1 unless you set another

Set the region. Requests are signed for the region you enter, and it defaults to us-east-1. On AWS, a bucket in any other region then refuses every request with a signature error: enter the bucket's own region, for example eu-central-1.

The endpoint is https://host or https://host:port with no path. The optional prefix (for example kymoscope/) puts every Kymoscope file under one folder of the bucket.

A key with the least rights

Create a key used only by Kymoscope. On AWS, attach this policy to its user (replace customer-history and kymoscope/ with your bucket and prefix):

policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "KymoscopeDayFiles",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:DeleteObject",
        "s3:AbortMultipartUpload",
        "s3:ListMultipartUploadParts"
      ],
      "Resource": "arn:aws:s3:::customer-history/kymoscope/*"
    },
    {
      "Sid": "KymoscopeBucket",
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetLifecycleConfiguration"],
      "Resource": "arn:aws:s3:::customer-history"
    }
  ]
}
  • s3:ListBucket lets a file that is really missing answer 404 instead of 403, so a deleted file reads as a gap and a permission problem reads as one.
  • s3:GetLifecycleConfiguration lets the setup check warn about missing lifecycle rules. Without it the check says "unknown" and still passes.
  • Cloudflare R2: an API token with "Object Read & Write" for this bucket only.
  • Backblaze B2: an application key restricted to this bucket (and prefix), with read and write.
  • MinIO and other on-site stores: a user with a policy of the same actions (see On site below).

The secret access key is entered once in the app and stored encrypted. Nobody can read it back, Kymoscope staff included; to change it, enter a new one.

CORS

Browsers read history straight from your bucket, so the bucket must allow the app's origin. The app shows this rule with a Copy button:

cors.json (AWS S3, B2, Wasabi, Hetzner, OVH)
[
  {
    "AllowedOrigins": ["https://app.kymoscope.com"],
    "AllowedMethods": ["GET", "HEAD", "PUT", "DELETE"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag", "Content-Length", "Content-Range"],
    "MaxAgeSeconds": 3600
  }
]
cors.json (Cloudflare R2)
{"rules":[{"allowed":{"origins":["https://app.kymoscope.com"],"methods":["GET","HEAD","PUT","DELETE"],"headers":["*"]},"exposeHeaders":["ETag","Content-Length","Content-Range"],"maxAgeSeconds":3600}]}
Shell
# Cloudflare R2 (cors.json holds the R2 rule above)
npx wrangler r2 bucket cors set customer-history --file cors.json

MinIO allows every origin by default. To narrow it, run mc admin config set <alias> api cors_allow_origin=https://app.kymoscope.com.

PUT and DELETE are there for the setup check, which runs in the browser. They open nothing: every request still needs a short-lived link signed by Kymoscope for your key.

Lifecycle

  • Add "abort incomplete multipart uploads after 7 days". An upload interrupted for good leaves parts behind; this rule cleans them. The setup check warns while it is missing.
  • Don't expire objects sooner than your raw data years, and not sooner than the 10 years of the 1-minute and 1-hour summaries. Expired files become gaps. Your collectors already delete expired and replaced files on schedule, so no expiration rule is needed.
  • Infrequent-access tiers are fine (S3 Standard-IA, Wasabi, B2). Archive tiers that need a restore (Glacier Flexible Retrieval, Deep Archive, Cold Archive) are not: reads of those days would fail.
  • WORM (Object Lock): the setup check shows whether the bucket accepts Kymoscope's writes. Files under retention can't be deleted when a day is replaced; the collector retries every hour until the retention ends.
lifecycle.json (AWS)
{
  "Rules": [
    {
      "ID": "kymoscope-abort-incomplete-uploads",
      "Status": "Enabled",
      "Filter": { "Prefix": "kymoscope/" },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}

Set it up in the app

  1. Update every collector of the organization to kymoscope-collector 0.5.0 or later. Older collectors can't upload to your bucket and wait with collector_update_needed.
  2. As an organization owner or admin, open Organization → Storage.
  3. Fill in Endpoint, Region, Bucket, Prefix (optional), Access key ID and Secret access key, then click Save bucket. From now on new days wait on your collectors until the bucket passes its test (or you click Use anyway): once a bucket is saved, they never go to Kymoscope storage.
  4. Click Test bucket. Your browser writes a small test file, reads part of it back (a range read, as charts do), deletes it and reads the lifecycle rules. When write, read, range and delete pass, new days go to your bucket.

Use anyway appears after a failed test. It is for a bucket this browser can't reach but your collectors and readers can, for example an on-site MinIO tested from outside the customer network. You decide; nothing is held back.

Lifecycle findings are warnings and never stop the test. Days stored before the switch stay where they are and remain readable.

One collector that doesn't follow

Every collector follows the organization's choice unless its collector.yaml sets recording.where:

collector.yaml
recording:
  where: kymoscope    # kymoscope: Kymoscope storage, even with a bucket saved
                      # own: the organization's bucket; omit to follow the organization

With where: own and no bucket saved, new days wait on the collector, and the log says so (no_own_storage), until an admin adds one in Organization → Storage.

On site

MinIO (or Garage, SeaweedFS, Ceph RGW) can run next to your own relay, inside the customer network. Kymoscope never connects to it: only your collectors and your browsers do. The server only signs short-lived links for it.

MinIO with Docker
# public.crt and private.key in /srv/minio-certs: a certificate your browsers trust
docker run -d --name minio --restart unless-stopped -p 9000:9000 \
  -v /srv/minio:/data -v /srv/minio-certs:/certs \
  -e MINIO_ROOT_USER=customer-admin -e MINIO_ROOT_PASSWORD='<a long random secret>' \
  pgsty/minio:RELEASE.2026-08-04T00-00-00Z server /data --certs-dir /certs

mc alias set customer https://minio.customer.local:9000 customer-admin '<a long random secret>'
mc mb customer/customer-history
MinIO user and CORS
# MinIO: allow the app's origin (MinIO allows every origin by default)
mc admin config set customer api cors_allow_origin=https://app.kymoscope.com

# a user with the same rights as the IAM policy above
mc admin policy create customer kymoscope-history policy.json
mc admin user add customer kymoscope-history '<a long random secret>'
mc admin policy attach customer kymoscope-history --user kymoscope-history

# MinIO removes incomplete uploads on its own (api stale_uploads_expiry, 24h by default)
mc admin config get customer api
  • Use a TLS certificate your browsers trust (your internal CA rolled out to the workstations is fine). Browsers refuse plain http:// storage from the app.
  • Viewers outside the customer network can't open those days: the chart says "Storage unreachable from this network". Everything else in the app keeps working for them.
  • Chrome may ask once to allow the app to access devices on your local network. Allow it for the app.

Health

The Storage tab shows the last test and what failed, the last upload, and the last storage error a collector reported. On the collector, the health stream carries, per source that records by itself:

ChannelWith your own storage
<source>.history_days_pendingClosed days not uploaded yet. Rises while the bucket is unreachable or refuses the key; 0 or 1 most of the time.
<source>.history_errorsFailed upload attempts since start, including refusals by your bucket. The collector log names the step and the status code.
<source>.history_storage_waiting1 while new days wait because the bucket hasn’t passed its test yet (storage_check_pending), recording.where is own and no bucket is saved (no_own_storage), or your own-storage contract has ended (customer_storage_not_in_plan). The collector log says what an admin must do.

Changing or removing the bucket

  • A change applies to new days only. Days already stored stay in the bucket they were written to, so keep the old bucket and its key valid until those days expire. After saving another bucket, new days wait on the collectors until it passes its test or an admin clicks Use anyway.
  • Rotating the key of the same bucket: enter the new key in the Storage tab and test again. Every stored day stays readable with the new key. This works even after your own-storage contract has ended, so stored days stay readable.
  • Stop using this bucket sends new days back to Kymoscope storage. Days already in your bucket stay there and stay readable.
  • Replaced and expired files in your bucket (a day rewritten with late data, a day past its retention) are deleted by your collectors, once an hour. Kymoscope can't reach your bucket to do it.
  • If the contract for your own storage ends while a bucket is saved, new days wait on the collectors (history_days_pending rises) until it is renewed or an admin clicks Stop using this bucket. Nothing is moved to Kymoscope storage, and stored days stay readable.

If you delete files yourself

A day whose file was deleted from your bucket shows as a gap in charts, never as an error. If late data arrives for that day, the collector rebuilds it from the values it still holds locally. An export leaves that day out and names it as missing from your storage.

A 403 is not a missing file. When your bucket refuses the collector's key while it reads a day back (to merge late data), the day waits and history_errors rises, with a permission problem in the log. Nothing is rebuilt from partial data. Fix the key's rights, and include s3:ListBucket so that a file that is really gone answers 404.

Troubleshooting

MessageWhat to do
customer_storage_not_in_planOwn storage is not on for your organization (or its end date has passed) while a bucket is saved. New days wait on the collector. Ask Kymoscope to renew it, or click Stop using this bucket to store new days with Kymoscope.
no_own_storageThe collector has recording.where: own but the organization has no bucket. New days wait on the collector. An organization admin adds one in Organization → Storage, or set where: kymoscope.
storage_check_pendingA bucket is saved but hasn’t passed its test yet. New days wait on the collector. An organization admin must test the bucket in Organization → Storage (or click Use anyway).
collector_update_neededThis collector is older than 0.5.0. Update it; the day it was uploading starts again cleanly.
write / read / delete: blocked (CORS, TLS or network)The browser could not talk to the bucket at all: no CORS rule for https://app.kymoscope.com, a certificate the browser does not trust, plain http://, or a host not reachable from this network.
write / read / delete: 403The key lacks a right, or the bucket name, prefix or region is wrong. Compare with the policy above.
A signature error (SignatureDoesNotMatch, AuthorizationHeaderMalformed)The region is wrong. On AWS enter the bucket’s own region; us-east-1 is only the default.
range: the bucket ignored the Range header or hides Content-RangeAdd Content-Range (and Content-Length, ETag) to ExposeHeaders in the CORS rule.
lifecycle: noneWarning only: add the abort-incomplete-uploads rule. On MinIO you can ignore it: MinIO removes incomplete uploads itself.
Storage unreachable from this network (in a chart)The viewer’s network can’t reach the bucket, typically an on-site store opened from outside the customer network.

Automation

Prepare the bucket from scripts with the files above (policy.json, cors.json, lifecycle.json):

AWS
# AWS CLI: CORS (cors.json holds the S3 rule above, wrapped as {"CORSRules": [...]})
aws s3api put-bucket-cors --bucket customer-history --cors-configuration file://cors.json

# lifecycle: abort incomplete uploads after 7 days (lifecycle.json above)
aws s3api put-bucket-lifecycle-configuration --bucket customer-history \
  --lifecycle-configuration file://lifecycle.json

# the least-rights policy for the collector's user (policy.json above)
aws iam put-user-policy --user-name kymoscope-history --policy-name kymoscope-history \
  --policy-document file://policy.json

Then alert when days pile up on a collector. history status lists each local day; more than one pending day for long means uploads to your bucket are failing. You can also chart and alert on history_days_pending in the health stream like any other channel.

watch-history-uploads.ps1 (Windows)
# watch-history-uploads.ps1: run hourly from Task Scheduler on the collector host
$out = & kymoscope-collector history status --config C:\kymoscope\collector.yaml
$pending = @($out | Select-String -Pattern ': pending').Count
if ($pending -gt 1) {
  Write-EventLog -LogName Application -Source "Kymoscope" -EventId 1002 -EntryType Warning `
    -Message "Kymoscope: $pending day(s) waiting to upload to your bucket. Check the Storage tab and the collector log."
  exit 1
}
watch-history-uploads.sh (Linux)
#!/bin/sh
# /etc/cron.d/kymoscope-uploads: 15 * * * * kymoscope /opt/kymoscope/watch-history-uploads.sh
pending=$(kymoscope-collector history status --config /opt/kymoscope/collector.yaml | grep -c ': pending')
if [ "$pending" -gt 1 ]; then
  logger -p user.warning "Kymoscope: $pending day(s) waiting to upload to your bucket"
  exit 1
fi