File Storage
Every app data backend comes with private object storage alongside its tables. Where the data backend holds your time-series rows, file storage holds everything that does not fit in a row: camera frames, PDF reports, firmware blobs, audio clips, exports.
The two are designed to be used together. Storing a file hands you back a permanent URL, and the intended pattern is to write that URL into a table column in the same breath — a dashboard widget then renders the image with no further work:
Python
info = await ironflock.files.put("part-1.jpg", jpeg_bytes, content_type="image/jpeg")
await ironflock.publish_to_table("inspections", part_id="1", photo_url=info.url)The full client API — reading, listing, usage, sharing links, large objects, error codes — is documented in the SDK reference. This page covers the platform side: how storage is declared, governed, shared and operated.
How it works
- Optionally declare a
files:section in.ironflock/data-template.yml. - When a user installs your app in a project, IronFlock provisions a private storage area for it — exactly as it provisions the project database.
- Your edge code stores and reads objects through the SDK’s
filesAPI. - Uninstalling the app removes its storage completely: every object and every credential, just as the database schema is dropped.
Like the database, storage is per project. The same app installed in two projects gets two fully separate storage areas, and as the app developer you have no access to either — the data belongs to the user running your app.
Zero configuration is a valid configuration: an app with no files: section still gets one namespace called default, so files.put(...) works out of the box for every app.
Declaring storage in the data template
The files: section lives beside data: in data-template.yml:
files:
description: Camera frames and generated inspection reports.
# Storage budget the app SUGGESTS for itself, in bytes (here 5 GiB).
# The project user can override it; their setting is what gets enforced.
quotaBytes: 5368709120
namespaces:
- name: frames
description: Raw camera frames, one JPEG per inspected part.
contentTypes: ["image/jpeg"]
maxObjectBytes: 20971520
retention: { deleteAfter: 30 days }
- name: reports
description: Generated PDF inspection reports.
contentTypes: ["application/pdf"]
private: trueNamespaces
A namespace is a key prefix that carries policy. It is not a separate storage bucket — every namespace of an app lives inside the app’s single storage area, and the namespace name simply becomes the first segment of each object’s storage path.
That distinction tells you when to declare one:
- Organising files? Use key paths —
2026/03/part-1.jpg— inside thedefaultnamespace. Folder-style keys are the normal case. - Different rules for a set of objects? Declare a namespace. Rules are the only thing a namespace adds.
The rules a namespace can carry:
| Field | Meaning |
|---|---|
name | Lowercase letters, digits and hyphens, starting with a letter. sys, system, ironflock and ironflock-* are reserved |
description | Shown to users and readable by AI agents |
private | Excludes the namespace from cross-app access. Defaults to false (shared), exactly like tables |
contentTypes | Allowed MIME types, globs permitted (image/*). Default: any |
maxObjectBytes | Largest single object, up to 5 GiB. Default 100 MiB |
retention.deleteAfter | Objects older than this are deleted automatically (30 days, 2 weeks, 1 year, …) |
The storage budget
quotaBytes is declared once for the whole app, not per namespace. A namespace is only a key prefix, so there is nothing a per-prefix budget could be enforced against — the quota applies to the app’s single storage area, and the object store itself enforces it on every write path, including direct uploads.
Two numbers exist, and the difference is deliberate:
- The suggested quota — what your template asks for. Applied when the app is first installed.
- The enforced quota — what the project user has set. Once a user changes the budget in the app’s storage settings, their value wins, and redeploying your app does not reset it.
If your template says nothing, the platform default applies (1 GiB for development backends, 10 GiB for production ones).
Retention
Objects past their namespace’s deleteAfter age are removed automatically — the file-side analogue of the table dropAfter policy. Retention runs inside the object store where the store supports it, and as a daily platform job where it does not (on-premises appliances), so declared retention behaves the same everywhere.
Permanent URLs and dashboards
Every stored object has a stable URL of the form https://files.ironflock.com/f/<backend>/<namespace>/<key>. Three properties make it the right thing to write into a table column:
- It never expires. The URL is a pure address; it stays valid for the life of the object.
- It is not a public link. Every request passes an authentication proxy that checks the requestor is signed in and holds READ access on this data backend — re-checked on every single request. Revoking a user’s access revokes their ability to fetch every file, immediately.
- It renders in an
<img>. The browser sends its session cookie automatically, so a dashboard widget can use the URL in<img src>,<video src>or a download link with no JavaScript involved.
For handing a file to someone outside the project, the SDK’s share_url mints an expiring bearer link instead — see sharing objects for when to use which.
Large files
Transfers up to 6 MiB travel as a single call through the messaging system. Anything larger is transported directly between the device and object storage over HTTPS — the SDK switches automatically, streams from and to disk, and a multi-gigabyte file never has to fit in memory. The single-upload ceiling is 5 GiB.
The direct path requires the device to reach the object-storage host (s3.ironflock.com), not just the messaging router. If a factory proxy allows only the router, large transfers fail with the explicit code PRESIGN_UNREACHABLE rather than a generic error — and a device clock more than 15 minutes out of step fails with CLOCK_SKEW, which is a prompt to check NTP, not credentials.
Sharing files between apps
Cross-app file access rides the same consent as cross-app table access. There is one switch: when a project user grants app B access to app A’s data (the data_access consent in the app’s settings), that grant covers A’s tables and A’s non-private file namespaces. Revoking it revokes both.
What your app controls as the provider is the private flag per namespace:
private: false(the default) — apps holding a data-access grant can read the namespace (never write it).private: true— the namespace is invisible to other apps, full stop, even under a grant.
This mirrors consuming app data exactly: namespaces and tables default the same way. Nothing is shared without the project user’s grant — private: narrows what an already-consented reader sees, it is not the consent itself.
The user’s view
Project users see and govern your app’s storage in two places:
- The Data view shows a Files entry beside every app’s tables and views — a searchable listing of every stored object with size, type and modification date, and per-file download.
- The app’s storage settings show usage (bytes and object count), the enforced budget beside your app’s suggestion, a control to change the budget, and a Delete all files action — the file-side twin of emptying all tables. Deletion is confirmed by typing the app’s name and cannot be undone.
- The AI assistant can list, search and read these files on a user’s behalf. The same
DATABACKEND/READcheck applies, so it never surfaces a file the user could not open themselves. It searches file paths rather than file contents, and it reads text files, images and PDFs — archives and other binary formats it cannot read.
As with tables, this is the user’s data: they can inspect it, cap it and delete it without involving you.
Direct S3 access
For everything beyond the SDK — an analyst with DuckDB, a nightly rclone backup, a BI pipeline — a project user can issue read-only S3 credentials per app from the app’s storage settings.
Each credential is scoped to that one app’s storage area: a credential issued for app A does not work for app B’s files, structurally — B’s storage policy simply never names it. A project can hold several credentials per app (one per consumer: a CI job, a backup script, a laptop), and revoking one leaves the others working. That is also the rotation story: if a key leaks, issue a second credential, move the consumer over, revoke the first — no other consumer is disturbed.
The secret is displayed once, at creation. Issuing requires read access to that app’s data backend — the same permission that lets you read the files in the first place, so the credential can never widen anyone’s reach.
# rclone
rclone config create myapp s3 provider=Other \
endpoint=https://s3.ironflock.com \
access_key_id=ifs-key-3317-x7k2m secret_access_key=<shown once>
rclone ls myapp:if-1042-3317# DuckDB
CREATE SECRET (TYPE S3, KEY_ID 'ifs-key-3317-x7k2m', SECRET '<shown once>',
ENDPOINT 's3.ironflock.com');
SELECT * FROM read_parquet('s3://if-1042-3317/exports/*.parquet');The storage settings show the endpoint and the exact bucket name to point a client at. Note that a top-level bucket listing (aws s3 ls with no argument) returns nothing by design — the credential owns no buckets; it is granted access to one. Address the bucket directly, as above.
On-premises appliances
File storage works identically on an on-premises appliance, with three differences that follow from the appliance’s design:
- Files are served same-origin under the appliance’s own address (
/files/...) — no extra DNS name, no extra certificate, and it works in plain-HTTP installations. Permanent URLs and<img src>behave exactly as in the cloud. - The appliance’s storage backend streams file downloads through the platform rather than redirecting to a separate storage host, so the storage service is never exposed as a second origin.
- Direct S3 credentials are not available on appliances — the embedded object store cannot express per-credential access grants. The section simply does not appear in the storage settings there. Everything else, including large-file behaviour through the SDK and declared retention, works the same.
An air-gapped appliance serves all file traffic locally: app logos, dashboard images and file downloads need no internet connectivity.
Limits at a glance
| Limit | Value | Where it comes from |
|---|---|---|
| Inline transfer (single call) | 6 MiB | Reported at runtime; may be raised server-side |
| Single object | 100 MiB default, 5 GiB max | maxObjectBytes per namespace |
| Single upload | 5 GiB | Object-store single-PUT ceiling; multipart is not yet available |
| Storage budget | 1 GiB dev / 10 GiB prod by default | Suggested by the template, decided by the user |
| Key length & characters | UTF-8, /-separated paths | ../, control characters and reserved prefixes are rejected |