- State & Storage
- Files
Files
The file storage API stores files for your app. You create a bucket, put files in it by path, and read them back. You can give an end user a short-lived URL to upload or download one file. You can also start a function when a file changes.
The API is the same in the Go, Node, browser and Python SDKs. It is a REST API under
/api/v1/files.
How it works
Section titled “How it works”For the mental model, see File Storage.
- A database catalog holds the file records: path, size, content type, SHA-256 and metadata.
- The bytes live in the blob backend (
blobs.url). The backend key is an opaque object ID. Your path never reaches the backend. - Every byte goes through Ironflow, also on S3. Ironflow checks the credentials, the size and the content type first.
- Each file has an ETag. It changes on every write.
Create a bucket
Section titled “Create a bucket”A bucket holds files and the rules for them. Create a bucket before you put a file in it.
import { createClient } from "@ironflow/node";
const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY });const files = ironflow.files();
await files.createBucket("inbox", { maxObjectBytes: 10 * 1024 * 1024, allowedContentTypes: ["image/*", "application/pdf"], emitEvents: true, allowSignedUrls: true,});
const inbox = files.bucket("inbox");client := ironflow.NewClient(ironflow.ClientConfig{ APIKey: os.Getenv("IRONFLOW_API_KEY"),})
maxBytes := int64(10 << 20)emit, signed := true, truetypes := []string{"image/*", "application/pdf"}
files := client.Files()_, err := files.CreateBucket(ctx, "inbox", ironflow.FileBucketConfig{ MaxObjectBytes: &maxBytes, AllowedContentTypes: &types, EmitEvents: &emit, AllowSignedURLs: &signed,})
inbox := files.Bucket("inbox")from ironflow import IronflowClient
client = IronflowClient(server_url="http://localhost:9123", api_key="ifkey_...")
client.files_buckets(body={ "name": "inbox", "maxObjectBytes": 10 * 1024 * 1024, "allowedContentTypes": ["image/*", "application/pdf"], "emitEvents": True, "allowSignedUrls": True,})The Python client has no bucket handle. Each method takes the bucket name as its first argument. The other bucket calls are files_list_buckets, files_get_buckets, files_patch_buckets and files_delete_buckets.
import { ironflow } from "@ironflow/browser";
ironflow.configure({ serverUrl: "https://ironflow.example.com", auth: { token: session.accessToken } });const files = ironflow.files();
await files.createBucket("inbox", { emitEvents: true, allowSignedUrls: true });const inbox = files.bucket("inbox");| Option | Default | Meaning |
|---|---|---|
name | required | 1 to 63 characters. Lowercase letters, digits, - and _. The first character is a letter or digit. |
maxObjectBytes | the server cap | The largest file in this bucket. It can be lower than the server cap. It cannot be higher. |
allowedContentTypes | [] (any type) | A list of patterns such as image/* or application/pdf. A pattern ignores media type parameters. |
emitEvents | false | When true, file changes in this bucket emit events. |
allowSignedUrls | false | When true, you can create signed URLs for this bucket. |
You can change these options later (updateBucket, files_patch_buckets). A lower cap or a
narrower type list applies to new writes only. Old files stay.
You can delete a bucket only when it is empty. A bucket with files returns 409.
Upload and download
Section titled “Upload and download”A path has 1 to 1024 bytes. It is valid UTF-8. It has no control characters, no leading /, no
empty segment, and no . or .. segment. Use / to make folders. A folder is only a prefix
of the path. You do not create it.
const info = await inbox.put("scans/2026/a.pdf", pdfBytes, { contentType: "application/pdf", metadata: { source: "scanner-1" },});console.log(info.etag, info.size, info.sha256);
const file = await inbox.get("scans/2026/a.pdf");const bytes = await file.arrayBuffer(); // or file.text(), or read file.body as a stream
await inbox.delete("scans/2026/a.pdf");put takes a Uint8Array, ArrayBuffer, Blob or string. It also takes a Node Readable or a
ReadableStream. A stream needs contentLength:
import { createReadStream, statSync } from "node:fs";
await inbox.put("big.bin", createReadStream("big.bin"), { contentType: "application/octet-stream", contentLength: statSync("big.bin").size,});f, _ := os.Open("a.pdf")defer f.Close()st, _ := f.Stat()
info, err := inbox.Put(ctx, "scans/2026/a.pdf", f, st.Size(), ironflow.PutFileOptions{ ContentType: "application/pdf", Metadata: map[string]string{"source": "scanner-1"},})
obj, err := inbox.Get(ctx, "scans/2026/a.pdf", ironflow.GetFileOptions{})if err != nil { return err}defer obj.Body.Close()data, _ := io.ReadAll(obj.Body) // obj.ETag, obj.ContentType, obj.Size
err = inbox.Delete(ctx, "scans/2026/a.pdf")Put needs the size, because the server needs Content-Length. A missing file is
*ironflow.IronflowError with Code == "not_found".
info = client.files_update_buckets_objects( "inbox", "scans/2026/a.pdf", pdf_bytes, content_type="application/pdf", extra_headers={"X-Ironflow-Meta-source": "scanner-1"},)print(info["etag"], info["size"])
with client.files_get_buckets_objects("inbox", "scans/2026/a.pdf") as resp: data = resp.read() # or: for chunk in resp.iter_bytes(): ...
client.files_delete_buckets_objects("inbox", "scans/2026/a.pdf")client.files_get_buckets_info("inbox", "scans/2026/a.pdf") # metadata onlyA body is bytes or a seekable file object. Python has no stream upload in this release.
const info = await inbox.put("avatars/me.png", fileFromInput, { contentType: fileFromInput.type });
const file = await inbox.get("avatars/me.png");const text = await file.text();A browser body is a Blob, File, ArrayBuffer, Uint8Array or string.
Rules for every upload:
- The request must have a
Content-Length. Without it, the server returns411. - The size must be at most the effective cap: the lowest of the server cap, the bucket cap
and (for a signed upload) the token
maxBytes. A larger file returns413. A body longer than itsContent-Lengthalso fails. Content-Typeis required. It must match the bucketallowedContentTypes, or the server returns415.- User metadata keys use
a-z,0-9and-. All metadata together is at most 2 KiB. - Two writes to one path at the same time: the last commit wins. A reader sees the old version or the new version, never a mix.
Do not overwrite by accident
Section titled “Do not overwrite by accident”Use a precondition to make a write safe:
ifNoneMatchcreates the file only when the path is free.ifMatch: etagoverwrites only that version.
A failed precondition returns 412 (PreconditionFailedError in TypeScript,
ErrPreconditionFailed in Go).
await inbox.put("a.txt", bytes, { contentType: "text/plain", ifNoneMatch: true });
const current = await inbox.info("a.txt");await inbox.put("a.txt", newBytes, { contentType: "text/plain", ifMatch: current.etag });You can also pass ifMatch to a download. Ironflow then returns 412 if the file changed, so
you read the exact version you expect. A download also supports range.
Retries
Section titled “Retries”An upload that you can send again is retried after a retryable failure (for example 503). This
means bytes, a Blob, and (Go and Python) a seekable reader that the SDK rewinds. A stream
cannot be sent again. A stream upload is sent once and fails with a clear message on the first
error. Python rejects a body that is not bytes and not seekable before it sends a request.
The two sign calls are retried in every SDK. A sign call only creates a URL, so a second attempt does no harm. Create, move and copy are sent once, because a retry after a lost response could repeat the change.
Byte transfers have no fixed timeout in the SDKs. Cancel a Go upload with its context.
List files
Section titled “List files”A listing is ordered by path. Use prefix to select a folder. Use delimiter: "/" to get one
level of folders.
let cursor: string | undefined;do { const page = await inbox.list({ prefix: "scans/", delimiter: "/", cursor, limit: 100 }); for (const folder of page.prefixes) console.log("folder", folder); // "scans/2026/" for (const f of page.files) console.log(f.path, f.size); cursor = page.nextCursor;} while (cursor);cursor := ""for { page, err := inbox.List(ctx, ironflow.ListFilesOptions{ Prefix: "scans/", Delimiter: "/", Cursor: cursor, Limit: 100, }) if err != nil { return err } // page.Prefixes: "folders". page.Files: files at this level. if page.NextCursor == "" { break } cursor = page.NextCursor}cursor = Nonewhile True: page = client.files_list_buckets_objects( "inbox", prefix="scans/", delimiter="/", cursor=cursor, limit=100, ) # page["prefixes"]: "folders". page["files"]: files at this level. cursor = page.get("nextCursor") if not cursor: breaklimit is 100 by default and 1000 at most. Without delimiter, the listing returns every
file under the prefix.
Move, copy and directory move
Section titled “Move, copy and directory move”| Operation | What it does | Bytes move? |
|---|---|---|
move(from, to, { toBucket?, ifMatch? }) | Renames one file. It can go to another bucket. Returns 409 if to exists. | No |
copy(from, to, { toBucket? }) | Makes a second file with the same bytes. Returns 409 if to exists. | Yes |
movePrefix(from, to) | Renames every file under a prefix, in one transaction. Returns the count. | No |
await inbox.move("scans/a.pdf", "archive/a.pdf");await inbox.copy("archive/a.pdf", "backup/a.pdf", { toBucket: "backups" });
const count = await inbox.movePrefix("scans/2026/", "archive/2026/");_, err := inbox.Move(ctx, "scans/a.pdf", "archive/a.pdf", ironflow.MoveFileOptions{})_, err = inbox.Copy(ctx, "archive/a.pdf", "backup/a.pdf", ironflow.CopyFileOptions{ToBucket: "backups"})count, err := inbox.MovePrefix(ctx, "scans/2026/", "archive/2026/")fmt.Println(count)client.files_buckets_move("inbox", body={"from": "scans/a.pdf", "to": "archive/a.pdf"})client.files_buckets_copy("inbox", body={"from": "archive/a.pdf", "to": "backup/a.pdf", "toBucket": "backups"})res = client.files_buckets_move_prefix("inbox", body={"from": "scans/2026/", "to": "archive/2026/"})print(res["count"])Directory move rules:
fromandtoend in/. Neither one can start with the other.- It works inside one bucket.
- It moves at most 10,000 files. A larger prefix returns
422. Nothing moves. - The whole move succeeds, or nothing changes. If a target path exists, you get
409. If a new path would be longer than 1024 bytes, you get400. - It emits one
ironflow.file.prefix_movedevent, not one event for each file. A prefix with no files returns a count of0and emits no event. - Directory copy is not available in this release.
Signed URLs
Section titled “Signed URLs”A signed URL lets an end user upload or download one file with no Ironflow credentials. The token in the URL is the credential. Your backend creates the URL. The browser uses it.
- Set
allowSignedUrls: trueon the bucket. - Your backend signs a URL.
- The browser sends the file with a plain
fetch.
const { url, expiresAt } = await inbox.signUpload("avatars/user-42.png", { ttlSeconds: 600, maxBytes: 2 * 1024 * 1024, contentType: "image/png",});// Send `url` to the browser.
const download = await inbox.signDownload("avatars/user-42.png", 3600);up, err := inbox.SignUpload(ctx, "avatars/user-42.png", ironflow.SignUploadOptions{ TTL: 10 * time.Minute, MaxBytes: 2 << 20, ContentType: "image/png",})// Send up.URL to the browser.
down, err := inbox.SignDownload(ctx, "avatars/user-42.png", time.Hour)up = client.files_buckets_signed_urls_upload("inbox", body={ "path": "avatars/user-42.png", "ttlSeconds": 600, "maxBytes": 2 * 1024 * 1024, "contentType": "image/png",})# Send up["url"] to the browser.
down = client.files_buckets_signed_urls_download("inbox", body={ "path": "avatars/user-42.png", "ttlSeconds": 3600,})import { uploadToSignedUrl, downloadFromSignedUrl } from "@ironflow/browser";
const info = await uploadToSignedUrl(url, fileFromInput); // content type from the Blobconst res = await downloadFromSignedUrl(downloadUrl); // a fetch Responseconst blob = await res.blob();These helpers send no Ironflow credentials and do not need configure().
Signed URL rules:
- The default TTL is 900 seconds. The maximum is 604800 seconds (7 days).
- An upload URL can carry
maxBytesandcontentType. The request must follow both. A wrong content type returns415. - A signed upload cannot set user metadata. A request with
X-Ironflow-Meta-*headers returns400. - An upload URL can carry
createOnly: true. The PUT then acts asIf-None-Match: *: it returns412when the path already exists, so the URL cannot replace a file. - A URL works many times until it expires. Without
createOnly, a second upload overwrites the first. There is no revocation for one URL. - A download URL is bound to the path, not a file version. Replacing the file, or deleting and
re-uploading it at the same path, lets an existing URL serve the new content until it expires.
Deleting the file returns
404while the path is absent. - To revoke every URL of a bucket, set
allowSignedUrls: false. Ironflow checks this flag on every use. If you delete a bucket and make a new bucket with the same name, the old URLs stay dead. - A bad, tampered or expired token returns
403. A bucket withallowSignedUrls: falsereturns403with codeSIGNED_URLS_DISABLED. - Set
files.publicBaseURLwhen a proxy rewrites the request host or adds a path prefix. Without this setting, the URL uses the request host. Behind a TLS proxy, sendX-Forwarded-Proto: https. - The signing key comes from
auth.jwtSecret. If you change the JWT secret, every URL stops working.
The browser makes cross-origin requests to Ironflow. The server answers with
Access-Control-Allow-Origin: * and no credentials, so a credential-free fetch works.
Events
Section titled “Events”When a bucket has emitEvents: true, a file change emits an event. A function can use the event
as a trigger.
| Event | When |
|---|---|
ironflow.file.created | A new path is written, or a file is copied. |
ironflow.file.updated | A path is overwritten. |
ironflow.file.deleted | An existing file is deleted. |
ironflow.file.moved | A single-file move. Emitted if the source bucket has emitEvents. |
ironflow.file.prefix_moved | A directory move that moved at least one file. |
ironflow.bucket.created | A bucket is created. |
ironflow.bucket.updated | A bucket is updated. |
ironflow.bucket.deleted | A bucket is deleted. |
Bucket events are always emitted. emitEvents controls file events only.
Payloads:
file.created / file.updated / file.deleted { bucket, path, size, contentType, sha256, etag, metadata, previousEtag, // file.updated only via: "api" | "signed_url" }file.moved { bucket, toBucket, from, to, etag, size, contentType }file.prefix_moved { bucket, from, to, count }bucket.created { bucket, bucketId, config }bucket.updated { bucket, bucketId, config, previous } // previous: changed fields onlybucket.deleted { bucket, bucketId }config = { maxObjectBytes, allowedContentTypes, emitEvents, allowSignedUrls }Use a wildcard trigger and a CEL expression to choose a bucket:
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
const onInbox = ironflow.createFunction( { id: "process-inbox-file", triggers: [{ event: "ironflow.file.created", expression: "data.bucket == 'inbox'" }], }, async ({ event, step }) => { const { bucket, path, etag } = event.data as { bucket: string; path: string; etag: string };
return step.run("read-file", async () => { // ifMatch pins the version that caused the event. const file = await client.files().bucket(bucket).get(path, { ifMatch: etag }); return { size: file.size, text: await file.text() }; }); },);Delivery rules:
- The event and the file change commit in one transaction. Both commit, or neither does.
- A recovery sweep runs every 30 seconds. It dispatches events that a crash left unprocessed. It waits at least 30 seconds and retries for up to 24 active hours from the committed file event. Persisted intentional instance-freeze intervals pause that age budget; ordinary downtime does not. New file execution events are refused while frozen. Ten genuine per-function failures quarantine an event sooner. Frozen or dispatch-wide failures do not consume that failure budget.
- Pending accepted file events survive ordinary unreferenced-event retention until processing or explicit quarantine. Quarantine preserves its reason and attempts for the event’s lifetime; terminal events remain subject to ordinary retention.
- This clock starts with migration 122 for newly committed file events. Older rows retain the previous 24 wall-hour policy. Upgrade cannot recover deleted events or previously pruned attempt history.
- Function triggering is therefore at-least-once within that recovery window. A function can run more than once for one event.
- Make your function idempotent. Use the
etagas the key. Theetagis new on every write. - The sweep does not recover wait-for-event wake-ups.
- Pass
ifMatch: etagwhen you read the file. If someone overwrote the file, you get412. The newer write has its own event.
The names ironflow.file. and ironflow.bucket. are reserved. Only the platform emits
events with these prefixes. A client emit of such a name is rejected. This also applies to
entity events, webhook event prefixes and the NATS ingest path. The whole ironflow. prefix belongs
to Ironflow. A client can emit only the writable defined name ironflow.invoke.<fn>. All reserved names are listed in
Reserved names.
Platform events do not pass through emit. Event schema registration rejects every name
that a client cannot emit, ironflow.file.* and ironflow.bucket.* included, with
invalid_argument. A platform event never has a schema.
Dashboard
Section titled “Dashboard”The dashboard has a Files page. Use it to browse buckets, upload and download files, and
view bucket settings. It uses the same REST API as the SDKs. Open Files in the Store
group of the sidebar. The route is /files. The route /files/<bucket> opens one bucket.
If the server has no blob backend, the page shows “File storage is not enabled”. See Storage backend and configuration.
The three panes
Section titled “The three panes”- Left: bucket tree. It lists the buckets. Select a bucket to open it. The folders of the
open bucket show below it. If a folder level has more entries than one page, select
More… at the end of that level. The New bucket button shows only with
files:manage. - Center: files. Buttons show the bucket and the open folders. Select a name in the path to go
back to that level. The toolbar has Events and, with
files:write, Upload. A tile shows each folder and each file. Select a tile to see it in the inspector. Double-click a folder tile, or press Enter on it, to open it. With no bucket selected, the center shows a card for each bucket. If a folder has more entries than one page, use Load more. - Right: inspector. It shows the item that you select:
- A bucket: the bucket settings.
- A file: a preview, the file details and the file actions.
- A folder: the Open and Move folder actions. The inspector does not show a file count for a folder.
Create a bucket in the dashboard
Section titled “Create a bucket in the dashboard”- Select New bucket.
- Enter a name. The name rules are the same as in Create a bucket.
- Set the maximum object size in MiB. The dialog shows the server cap beside this field. A
value above the server cap returns
400. - Optional: enter allowed content types, separated by commas. Leave the field empty to allow any type.
- Set Emit ironflow.file.* events and Allow signed URLs. The dialog starts with events on and signed URLs off.
- Select Create bucket.
Existing bucket settings are read-only in the inspector. Change them through the SDK or REST API. A lower cap or narrower types apply to new writes only. Old files stay.
Upload
Section titled “Upload”Open a bucket and a folder. Then do one of these:
- Select Upload and choose one or more files.
- Drag files from your computer onto the center pane. The dashboard does not upload folders. Drop files.
The file goes to the open folder, with its own name as the last path segment. If a file
exists at that path, the dashboard refuses the upload. The row shows “A file with this name
already exists in this folder. Move or delete it first.” The dashboard sends If-None-Match: *,
and the server returns 412. The API itself replaces a file when you send no precondition.
Next to Upload, the page shows the largest size that you can upload. This size is the lower
of the bucket cap and the server cap.
Before it sends a request, the dashboard checks the file size and the content type against the bucket rules. The dashboard does not send a file that fails a check. A row shows the reason. A file that passes the checks uploads, and its row shows the progress. Each file has its own row. Select the X on a row to dismiss it.
The dashboard uploads at most four files at the same time. The other files wait in their rows.
If the server is busy with other uploads, it returns 503. The dashboard tries that file again
one time, after the delay that the server sends in Retry-After. If you open a different
bucket or leave the page, the dashboard stops the uploads that are not complete.
If the server still rejects a file, the row shows the message from the server. A file that is
too large returns 413. A content type that the bucket does not allow returns 415. See
Limits and errors.
Download and preview
Section titled “Download and preview”Select a file. The inspector shows its size, content type, SHA-256, ETag, dates and metadata. Download saves the file to your computer. For a file over 25 MiB, the dashboard creates a signed download URL that expires after 60 seconds, and the browser downloads from it. Thus the file does not go into the memory of the page. If the bucket does not allow signed URLs, the dashboard does not download a file over 25 MiB and shows a message. Use an SDK or the API.
The inspector previews a file only for these types:
- Images: PNG, JPEG, GIF, WebP and AVIF
- PDF. The preview shows inside the page in browsers that support it. Other browsers show a short note. Use Download then.
- Plain text (
text/plain). The preview shows the first 256 KiB. The dashboard asks the server for only these bytes. - Audio and video
A file over 25 MiB does not preview. The inspector shows “Preview is off for large files. Download to view.” Use Download to view it.
The preview request sends the ETag from the file list in If-Match. If the file changed after
the list loaded, the server returns 412. The inspector then shows “This file changed after the
list loaded. Reload to preview it.”
Every other type shows “No inline preview. Download to view this file.” This includes text/html
and image/svg+xml. These files can run script. The dashboard never renders them. The server
also serves them as attachment. See Serving rules.
Signed download URL
Section titled “Signed download URL”A signed download URL lets a person without Ironflow credentials download one file. For the rules, see Signed URLs.
- Select a file in a bucket that has Allow signed URLs on. If the bucket does not allow signed URLs, the Copy signed URL button does not show.
- Select Copy signed URL.
- Set Expires in (seconds). The default is 900 (15 minutes). The maximum is 604800 (7 days). You can also select a preset: 15m, 1h, 24h or 7d. The dialog changes a value outside the range to the nearest limit.
- Select Generate, then Copy.
The dialog shows the URL until you close it or change the expiry. The dashboard does not store the URL. When you close the dialog, the URL is gone. If you change the expiry, the dialog clears the URL. Generate a new one.
Move, copy and delete
Section titled “Move, copy and delete”- Move renames a file inside its bucket. Enter the new path.
- Copy makes a second file. Choose the target bucket and enter the new path.
- Delete asks you to confirm. Then it deletes the file. You cannot undo this.
- Move folder renames every file under a folder, in one operation. The dialog shows how many files it moves. The count is exact up to 1,000. For a larger folder, the dialog says “more than 1,000 files”. The dialog also states the limit of 10,000 files. A larger folder returns an error and nothing moves. See Move, copy and directory move.
Move, copy and delete send the ETag of the file you inspected in If-Match. If the file
changed, the server returns 412 and the dashboard shows “This file changed. Reload and try
again.” A destination path keeps its leading or trailing spaces until you edit it.
If a target path exists, the server returns 409 and the dashboard shows the message.
Events link
Section titled “Events link”When a bucket is open, the toolbar has an Events link. It opens the Events page with a filter
for these names: ironflow.file.created, ironflow.file.updated, ironflow.file.deleted,
ironflow.file.moved and ironflow.file.prefix_moved. See Events.
The list shows these events for the whole environment. It does not show only the events of the open bucket, because the Events API cannot filter by bucket. A bucket sends file events only when Emit ironflow.file.* events is on.
Delete a bucket
Section titled “Delete a bucket”Select the bucket, then select Delete bucket in the settings. You can delete only an empty
bucket. A bucket with files returns 409, and the settings show the message. Move or delete the
files first.
What each permission shows
Section titled “What each permission shows”The dashboard hides a control that you cannot use. See Permissions.
| Permission | Controls |
|---|---|
files:read | Browse buckets and folders. Preview and download files. Create a signed download URL. |
files:write | Upload, move, copy, delete and move folder. |
files:manage | New bucket and Delete bucket. |
A user with only files:read sees Download and Copy signed URL for a file, and Open
for a folder. Copy signed URL shows only when the bucket has Allow signed URLs on. Bucket settings are a read-only summary for all users.
A platform user sees file controls only while they impersonate an organization. Then
platform:impersonate:read gives the files:read controls, and platform:impersonate gives all
controls.
Serving rules
Section titled “Serving rules”Ironflow serves files from the same origin as the dashboard. To keep a stored file from running script in that origin, every download has these rules:
X-Content-Type-Options: nosniffandContent-Security-Policy: sandbox.Content-Disposition: inlineonly forimage/png,image/jpeg,image/gif,image/webp,image/avif,application/pdf,audio/*,video/*andtext/plain.- Every other type, including
text/htmlandimage/svg+xml, getsattachment. The browser saves the file. It does not render it.
To show HTML or SVG to a user, serve it from your own origin.
Limits and errors
Section titled “Limits and errors”| Limit | Value |
|---|---|
| Server cap per file | 100 MiB by default. Set files.maxObjectBytes. |
| Bucket cap | At most the server cap. |
| Path | 1 to 1024 bytes. |
| User metadata | Keys [a-z0-9-], 2 KiB in total. |
| List page | 100 by default, 1000 at most. |
| Directory move | 10,000 files. |
| Signed URL TTL | 900 s by default, 604800 s at most. |
| Upload idle time | 30 s without a byte ends the upload (408). A slow, steady upload is not cut off by this alone. A single upload or copy may also run at most 45 minutes in total, however steadily the client sends, including the time the storage backend takes to finish the write. After that the request fails with an upload-timeout error. |
| Concurrent uploads | 32 per node, with at most 8 per principal even when the node has spare capacity. Either cap returns 503, code TOO_MANY_UPLOADS, and Retry-After: 5. |
Each API key or authenticated user has one upload allowance per node, shared across environments and buckets. Direct uploads and signed upload URLs issued by that principal share the same allowance. If a backend issues URLs for different end users with one API key, all those URLs share that key’s quota.
Older signed URLs without an issuer identity remain valid and share an 8-upload allowance per environment, separately from identified callers. These URLs can remain valid for up to 7 days after the last one is issued. Each node enforces its own quotas; upgrade all serving nodes to apply the principal limit throughout a cluster.
| Status | Cause | TypeScript | Go |
|---|---|---|---|
| 400 | Bad path, bucket name, metadata or prefix. A bucket cap above the server cap. A directory move that makes a path too long. | IronflowError | *IronflowError |
| 401 | No valid credentials. | UnauthenticatedError | ErrUnauthorized |
| 403 | No permission. A bad or expired token. | UnauthorizedError | ErrForbidden |
| 403 | Signed URLs are off for the bucket (code SIGNED_URLS_DISABLED). | IronflowError, code SIGNED_URLS_DISABLED | ErrForbidden, Code == "SIGNED_URLS_DISABLED" |
| 404 | Bucket or file not found. | IronflowError, code not_found | *IronflowError, Code == "not_found" |
| 409 | Bucket not empty. A move or copy target exists. A directory move conflict. | ConflictError | ErrConflict |
| 408 | The upload sent no byte for 30 s, or ran longer than 45 minutes in total (code UPLOAD_TIMEOUT). | IronflowError, code UPLOAD_TIMEOUT | *IronflowError, Code == "UPLOAD_TIMEOUT" |
| 411 | No Content-Length. | IronflowError | *IronflowError |
| 412 | If-Match, If-None-Match or a createOnly URL failed. | PreconditionFailedError | ErrPreconditionFailed |
| 413 | Larger than the effective cap. | PayloadTooLargeError | ErrPayloadTooLarge |
| 415 | Content type not allowed. | UnsupportedMediaTypeError | ErrUnsupportedMediaType |
| 422 | A directory move of more than 10,000 files. | IronflowError | *IronflowError |
| 503 | The blob backend is unavailable, or it could not serve a read (code BLOB_BACKEND_UNAVAILABLE). No shared signing key in a cluster. The node or principal upload cap is full (code TOO_MANY_UPLOADS, with Retry-After: 5). | IronflowError | *IronflowError |
In Python, every error is an IronflowError with a status_code. PreconditionFailedError,
PayloadTooLargeError and UnsupportedMediaTypeError are subclasses that you can import from
ironflow.
In Go, test an error with errors.Is(err, ironflow.ErrPreconditionFailed) (and the same for
the other sentinels).
A DELETE of a path that does not exist is not an error.
Permissions
Section titled “Permissions”| Permission | Routes | Admin | Developer | Viewer |
|---|---|---|---|---|
files:read | Get, info, list, bucket reads, signed-urls/download | yes | yes | yes |
files:write | Put, delete, move, copy, move-prefix, signed-urls/upload | yes | yes | no |
files:manage | Bucket create, update, delete | yes | yes | no |
The signed routes (/api/v1/files/signed) have no permission check. The token is the control.
A move or copy into another bucket (toBucket) also needs files:write on that bucket and
files:read on the source bucket.
A Rego policy sees the bucket in input.request.resource as irn:ironflow:{org}:{project}:file:{env}:{bucket}. The IRN names
the bucket. It does not name the file. A rule cannot match a path in this release.
Storage backend and configuration
Section titled “Storage backend and configuration”Files use the same backend as blob overflow: blobs.url (IRONFLOW_BLOB_URL). A multi-node
cluster needs an s3:// backend. Ironflow stores file bytes under the key prefix files/. Do
not add a lifecycle rule that expires that prefix.
The server cap is a separate setting:
spec: files: maxObjectBytes: "100MiB"Or set IRONFLOW_FILES_MAX_OBJECT_BYTES=100MiB. The environment variable overrides the YAML
value. A plain byte count such as 104857600 also works.
Public signed URL address
Section titled “Public signed URL address”When a reverse proxy rewrites Host, configure the address clients use to reach Ironflow:
spec: files: publicBaseURL: "https://files.example.com/ironflow"Or set IRONFLOW_FILES_PUBLIC_BASE_URL=https://files.example.com/ironflow. The environment
variable overrides the YAML value on both the flags-only and -f paths. The URL must use
http or https, include a host, and omit credentials, a query string and a fragment. An
optional path prefix is preserved; trailing slashes are removed. The example generates signed
URLs under https://files.example.com/ironflow/api/v1/files/signed. Configure the proxy to
forward that prefix to Ironflow’s /api/v1/files/signed route.
When unset, Ironflow keeps the request host and uses HTTPS if the request uses TLS or carries
X-Forwarded-Proto: https.
Deleting an environment deletes its buckets, file records and file bytes.
Background cleanup
Section titled “Background cleanup”Ironflow records retirement when a file is overwritten or deleted, or when a definite failed upload or copy can record its target for cleanup. Cleanup requires that committed evidence and checks that no current file references the object. Failed deletions retry hourly. Objects left by crashes, temporary files, and legacy objects without evidence remain in storage regardless of age. Database restores preserve unexplained bytes that still exist; they cannot recover bytes already deleted.
files.reconcile defaults to on, which retries recorded retirements. dry-run reports the
queue and inspects every prefix page, distinguishing eligible retirements, live objects,
conflicts, and unexplained objects without changing bytes or queue state. A backend without
object-info listing supports queue-only dry-run and logs that prefix inspection is unavailable.
off never starts the background worker. Explicit API overwrite and delete cleanup runs in
all three modes. IRONFLOW_FILES_RECONCILE overrides the YAML value.
Keep each deployment on its own bucket prefix. Distinct foreign IDs are protected after an accidental overlap, but writable catalog clones sharing the same physical object IDs remain unsupported. See File storage for the cleanup contract and metrics.
Not in this release
Section titled “Not in this release”- Multipart or resumable upload. Upload without
Content-Length. - Object versions. Directory copy. A directory move of more than 10,000 files.
- Per-file access control. Single-use or per-URL revocable signed URLs.
- Quotas for a bucket or an environment.