Modulify

Storage

Per project file storage served from your own CDN link.

On this page

Every project gets its own object storage: a bucket fronted by your own CDN link, where each file is either public at that link or private to your site. Use it for anything created or changed at runtime, such as form submissions, user uploads, generated files and datasets. Static assets that ship with the design belong in the site's public folder instead.

Open the Storage panel

Open a project and pick the Storage icon in the editor's toolbar, in the same pill as Preview, CMS and Analytics. The panel has three sub-tabs: Storage, Analytics and Configuration.

Browse and upload files

The Storage sub-tab is a file browser. Breadcrumbs run along the top starting from Storage, and folders open on click. The same row also carries the top level default on its right, described under Which files are private.

The folder you are in is part of the page address, under /storage/files/. A link to a folder reopens it, a refresh keeps you where you were, and the browser's back button walks you back out.

There are three ways to get files in:

  • Drag files onto the panel. A dashed overlay appears reading "Drop files to upload".
  • Open the three dots menu in the toolbar and choose Upload files.
  • Use the same menu's New folder first, if you want somewhere to put them. The dialog asks for a name and a Visibility of Inherit, Public or Private, which sets what new files inside the folder become. Inherit follows the folder around it, or the top level default.

Uploads run up to three files at a time, fewer while a large one is in flight, with a queue of up to 100 files. A single file is capped at 100 MB, and anything bigger is refused before it is read, with "File too large" naming the file, so a large file never sits there half sent. Folders cannot be uploaded: dropping one reads as an empty file and is refused the same way, so open it and drop the files inside instead. Hand over more than the queue holds and the extras are left for you to add once the first lot finishes.

A panel appears under the toolbar while the queue runs, reading "Uploading 1 of 3" with the bytes sent so far, and a row per file carrying its size and its state. A row reads Queued before its turn, Preparing while the file is being packaged, then a percentage, then Finishing while the server writes it. When it finishes you get a toast: "Uploaded", with a count. If some files fail you get "Some uploads failed" with the split, and each failed row says why underneath it. If a file was stored but an older copy of it with the other visibility could not be cleaned up, a second toast, "Older copy left behind", names it, or "Older copies left behind" counts them when there are several. Upload the same file again to finish, which keeps the new copy and removes the old one.

A file that fails on a server problem is sent again on its own, up to three tries, with the row reading "Retry 2 of 3" between attempts. A file the server refuses outright, such as one that is too large, is not retried. Nine filenames are refused that way in every folder, whatever their case: put, get, list, delete, create-folder, delete-folder, move, set-visibility and signed-url, because the CDN routes a path ending in one of them to the storage API instead of serving the file. The row reads "A file cannot be named put, get, list, delete, create-folder, delete-folder, move, set-visibility, signed-url, because the CDN routes those paths to the storage API. Pick another name!", so rename it on disk first. list.json or listing is fine, and so is a folder called list, because a folder has no address of its own. Anything still failed at the end keeps a circular arrow next to it that sends the same file again without you finding it on disk a second time.

Nothing is blocked while an upload runs. The three dots menu stays available, so you can create a folder or hand over more files and they join the back of the queue. Every unfinished row has an X that cancels that one file, stopping it mid transfer and dropping it from the list while the rest of the queue carries on, and Cancel all in the panel header stops the lot.

Uploading a file whose name is already in the folder asks first, because the upload replaces the old file for good and its address then points at the new content. The check runs against the whole folder, not just the files loaded on screen. Uploading while a search is open is refused with "Clear the search first", because the files would land in the folder you were browsing and the results would not show them. New folder and Upload files are greyed out in the menu for the same reason until you clear the search.

An empty folder reads No files, "Drag files here or upload them instead."

Work with what is there

The search box is placeholdered "Search all files…" and searches the whole bucket, not just the folder you are in. One search reads up to 5,000 files, shared between public and private ones, and shows at most 200 matches, so on a very large bucket it can stop before it reaches the file you want. When that happens and nothing matched, the empty list reads "Search stopped early, try browsing folders instead." instead of "Try a different name or clear search.", and opening the folders is the sure way to find it.

Each row has a three dots menu with View file, Download file, Copy link on a public file, Rename file, Move to and Delete file. A folder's menu has Open, Move to and Delete folder. Tick several rows and a bar appears above the list reading "3 selected", with its own menu holding Download, Move to and Delete. Download fetches the ticked files one by one and skips ticked folders, saying so in a toast, and is greyed out when only folders are ticked, because a folder is downloaded through Export instead. The same menus also carry the visibility actions described under Public and private.

Copy link puts the file's CDN URL on your clipboard and confirms with "Link copied". It only appears on a public file, because a private file has no link that keeps working. While a site's CDN link is not ready yet, the copied link is a temporary one that stops working after seven days, and the toast says so and asks you to copy it again once the CDN link is ready.

Drag a file or a selection onto a folder row, or onto a breadcrumb, to move it, or choose Move to from a row's menu or the selection bar. The dialog is placeholdered "Leave empty for the top level" and takes a folder path like images/2024, with the top level, the folders above you and the folders on screen offered as one click picks. A folder that does not exist yet is created by the move. Moving into the folder it already sits in, or a folder into itself, is ignored, and the dialog says so before you confirm.

One move covers at most 5,000 files, and the files you selected count toward that first. A folder that would take the move past it stays where it is, before any of its files move, even when it is the files you selected that used up the limit. You get "Folder not moved", or "folder was not moved (move limit)" in the toast when other items in the same move went through. Move that folder on its own, or what is inside it in smaller groups.

A move rewrites the file's path, and its CDN URL is built from that path, so the old URL stops working the moment the move lands. Everything already pointing at it breaks with it. A published page that links the file, or a CMS row whose image field holds the old address, keeps asking for a key that is no longer there and stops loading its image until you update it to the new URL. Nothing asks before a move and there is no undo, so it is worth arranging folders before you start linking files rather than after.

The Modified column reads in your own timezone, down to the minute.

Deleting asks first. A folder delete warns that everything inside goes too, and none of it can be undone. It also warns that the folder's privacy setting goes with it, so files added to that path later follow the parent folder or the top level default. It does the same to links as a move does: the URL stops resolving, and anything still pointing at it breaks.

Files load fifty at a time. The footer reads "Showing 50 of 312" with a Load more button while there is more to fetch. If the list changed underneath you, Load more starts the folder again from the top and says "File list reloaded". If loading more fails for another reason you get "Could not load more files", the rows already shown stay put, and Load more is still there to try again.

Renaming

Rename file in a file's menu renames one file in place. The dialog asks for the new name, the file keeps its folder, and a name that already exists there is refused with "Name already taken". The nine names the upload refuses, put, get, list, delete, create-folder, delete-folder, move, set-visibility and signed-url in any case, are refused here too, with "Could not rename" and the same reason underneath, because the CDN would route the renamed file to the storage API. Because a rename is a path change, the URL moves with it and whatever points at the old one needs updating, and the dialog says so.

Chat renames the same way, one file at a time by moving it to the new name, and it quotes the file and the destination back to you before it touches anything you did not just upload. Folders cannot be renamed at all, in the panel or in chat. They move into another folder and keep their own name.

The manual route is to upload the file again under the name you want and delete the old one. Either way the name change is a path change, so the URL moves with it and whatever points at the old one needs updating.

Public and private

Every file is either public or private. A public file is served at its CDN URL, https://your-cdn-link/<key>, to anyone who has the address. A private file has no CDN URL at all: the CDN cannot reach it, and only your site's server code can read it back, through the storage helper or the HTTP API with the storage key.

A private file shows a small lock after its name in the file browser. A folder shows a grey lock when its default is private, meaning new files dropped into it are private. The lock is amber when the folder holds files on the other side of its default, such as private files in a public folder or public files in a private one. Hover the lock to see which of these it means for that row.

A file only ever lives on one side. If a file ends up with a copy on both, for example after a change that was interrupted or an upload that could not remove the old copy, the browser, search and the listings show it as public, because the public copy is the one the CDN serves. Making it private removes the public copy, and making it public removes the private one.

Which files are private

Overwriting a file keeps the visibility it already has, so replacing an image never quietly republishes it. A new file follows the default of the folder it lands in. A folder's default is its own setting when it has one, otherwise the setting of the nearest parent folder that has one, otherwise the top level default. That top level default also covers every file uploaded outside any folder. It starts as Private.

The top level default sits on the right of the breadcrumb row in the Storage sub-tab, in every folder you open, and reads Top level: Private or Top level: Public. A grey lock sits in front of it when the default is private, the same lock a private folder shows. Hover it to see what it covers. Open it and pick New files here are public or New files here are private, with the current one highlighted. Nothing asks first, because nothing moves. You get "Default updated", and existing files keep their visibility.

A folder gets its own setting in two ways. Creating it sets one, from the visibility you give or from its parent when you give none, and creating a folder that already exists keeps the setting it has. Make folder public and Make folder private set one. Writing a file never does: a folder that only appeared because a file was written inside it has no setting of its own, and keeps following its parent or the top level default whatever visibility that file was given.

Changing the top level default later only affects new files outside any folder and new files in folders with no setting on them or on a parent folder. Existing files keep their visibility, and a folder with its own setting keeps it.

A folder's setting lives with the folder, so Delete folder and Clear storage remove it too. A file written into that path afterwards follows the parent folder, or the top level default when no parent has a setting. If your site writes private data, such as invoices or user uploads, pass visibility: 'private' on every write rather than relying on the folder.

Uploads from the panel follow those defaults, and so does a folder made with New folder unless you pick Public or Private as its Visibility in the dialog. Uploads from chat follow the defaults unless you say otherwise. CMS image and file fields always upload as public, because the CMS embeds them in pages. Your own site code picks per write with the visibility option on putObject or the /put body.

Changing visibility

A file's menu carries Make private when it is public and Make public when it is private. A folder's menu carries the action opposite to its default, Make folder private on a public folder and Make folder public on a private one, and both when the folder holds files on the other side of its default. Either one moves everything inside to that side and sets the folder's own default for files added later. Tick several rows and the selection bar's menu offers Make public and Make private for all of them, however many you picked.

Each change asks first. Making a file public opens it to anyone with the link, so the prompt reads Make "hero.png" public? and confirms with Make public. Making it private stops its public link right away, so the prompt reads Make "hero.png" private? and warns that your site can still read it. On success you get "Made public" or "Made private", and "Nothing was changed" when everything was already on that side.

A folder too large to change in one pass is changed in several passes behind that one prompt. If it is still not finished after them you get "Visibility partly changed", and running the same change again picks up the rest. Files that could not be moved are counted in "Some items were not changed" and keep their old visibility. A folder whose path is longer than 1001 bytes has no room for the file that keeps its visibility setting, so it is left as it is and the same toast counts it among the items that kept their visibility because their path is too long. Move that folder to a shorter path, then change it again. In each case the ticked rows stay ticked, so you can try again straight away.

Moving a file never changes its visibility: a private file dropped into a public folder stays private until you change it. A folder you move keeps the default it had, even when that default came from the folder it was moved out of.

Downloads you start from the panel use a temporary signed link. For a public file that is a convenience, not a protection. For a private file it is the only way the panel can hand you the bytes, and the link expires after fifteen minutes.

The Configuration sub-tab holds both, under Connection.

CDN link is the public base URL for this project's storage. Append a public file's path to it to link to that file, for example https://your-cdn-link/logo.png. Private files have no CDN link, and the same address answers 404 for them: the panel opens them through a temporary signed link, and your site reads them through the storage helper. Copy and open buttons sit beside it. If storage has not been provisioned yet, the card reads "Storage is not provisioned for this site." If the settings cannot be loaded, the card reads "The CDN link could not be loaded." Nothing about your files changes. Try again loads the settings again, and the message also clears by itself the next time the settings load, for example when the panel refreshes after your storage changes.

Private Storage Key is the server only key your site uses to read and write at runtime. It is masked as msk_•••• and never shown in full in the panel. The copy button puts the real key on your clipboard and clears it again after a minute, telling you so in the toast. Anyone holding the key can read, write and delete every file in the bucket, private ones included, and it is also what your published site presents to fetch its own build when it starts, so a leaked key exposes your compiled site as well as your files. Rotate it as soon as you suspect a leak.

Your site reads the key as CDN_PRIVATE_KEY and the base URL as CDN_URL, both injected into the environment. Both are server only. Never reference either from a client component.

Rotating the key

The rotate button next to copy asks "Rotate storage key?" and warns that the current key stops working immediately, that your published site keeps the old key until you republish it, and that anything outside the site that uses the key needs the new one pasted in. On success: "Key rotated", with the reminder to republish the site to apply it.

Two things then need doing, and republishing covers only one of them. Your published site keeps the key it was built with, so its own storage calls fail until you publish again, which bakes the new key in. Anything outside the site that used the old key, a script, a CI job or another service calling the HTTP API, is not fixed by a republish: copy the new key and paste it in there yourself.

Reading and writing from your site

Sites created from the Modulify starter ship with a server only helper at src/lib/modulify/storage:

import { putObject, listObjects, getObjectText, getSignedUrl, setVisibility, deleteObjects, StorageError } from '@/lib/modulify/storage'

await putObject('submissions/123.json', JSON.stringify(data), { visibility: 'private' })
await putObject('uploads/photo.jpg', photoBytes, { contentType: 'image/jpeg', visibility: 'public' })
const { files } = await listObjects('submissions/')
const raw = await getObjectText('submissions/123.json')
const { url } = await getSignedUrl('invoices/2026-001.pdf', { download: true })
await setVisibility(['brochures/2026.pdf'], 'public')

try {
    await deleteObjects(['uploads/old-1.jpg', 'uploads/old-2.jpg'])
}

catch (error) {
    if (error instanceof StorageError &#x26;&#x26; error.data &#x26;&#x26; 'failedKeys' in error.data) await deleteObjects(error.data.failedKeys)
}

Import it only from API routes, getServerSideProps or other server code. It throws in the browser by design. putObject and createFolder take visibility in their options, getObject and getObjectText read private files too, listObjects returns rootVisibility beside files, folderDetails and nextToken, the default a new file gets at the top level, deleteObject and deleteObjects return deleted and missing, the keys that had no file to remove, and setVisibility changes existing files or whole folders.

The putObject body is a string, a Buffer or a Uint8Array. Hand binary data over as bytes, for example a file a visitor uploaded, and the helper base64 encodes them and sets encoding to base64 for you. The encoding option only matters for a string body: base64 when the string already is base64, otherwise it is treated as UTF-8. Bytes still travel inside the JSON request as base64, a third bigger than the file, but the 140 MB request body limit leaves room for the 100 MB object limit, so a file can be 100 MB whichever form you pass.

getObject and getObjectText return files up to 25 MB and refuse anything larger. To hand a visitor a private file, or any file over that size, check who is asking in your API route and redirect them to the link getSignedUrl returns. It works for fifteen minutes, and download: true makes it a save-as download. Never store that link or put it in a page, because it stops working.

Every function throws when the endpoint refuses the request, and what it throws is a StorageError carrying message, status, the HTTP status the endpoint answered, and data, the counts the endpoint sent with its refusal. data is deleted, missing, failed and failedKeys when deleteObject or deleteObjects could remove only some keys, and the full result (changed, skipped, failed, tooLong, complete and visibility) when setVisibility could not move every file or met a folder whose path is too long. For every other refusal data is null. Check error instanceof StorageError, then send failedKeys again, or the same setVisibility call again, exactly as the HTTP API describes. Misusing the helper itself, an import from the browser, an option it does not know, a body that is not a string or bytes, or a site with no storage configured, throws a plain Error instead.

deleteFolder and setVisibility work through a large folder in parts. Each returns complete, and while it is false you call it again with the same arguments. setVisibility also returns tooLong, the keys it left untouched because they are over the key length limits: a file key longer than 1016 bytes, or, the only case that happens in practice, a folder whose path is longer than 1001 bytes and so has no room for the marker file that stores a folder's default. When tooLong is above zero, nothing failed and complete is true, the call throws "The object key is too long!" with every count in error.data, and the fix is to move that folder to a shorter path before changing its visibility.

A helper written before private storage existed lacks setVisibility, getSignedUrl and the visibility option, and one written before errors carried their counts throws a plain Error and takes only a string body in putObject. Chat adds whatever is missing to src/lib/modulify/storage.ts when it works on storage, and each function maps one to one onto an endpoint in the HTTP API.

Caching is handled for you. Objects are cached hard at the edge, but every write, move and delete purges that object, so the next fetch returns the latest bytes. Reuse the same key to update a file in place rather than adding a cache busting query string.

You rarely need to write this yourself. The Configuration sub-tab has a collapsible Using your storage section with two tabs. Modulify explains that describing what you want in chat is enough, and its Add it with AI button drops a ready made prompt into the composer for you to review and send. External holds the raw endpoint reference with your own host filled in.

For calling the endpoints directly, from outside the site or from another language, see Storage HTTP API.

Usage and limits

Each site is expected to stay under 500 GB. That is a soft limit: nothing in the panel or the HTTP API refuses a write for crossing it, and it can be raised on request.

The Analytics sub-tab shows Total data, Files and Folders for the bucket. Those three are counted by scanning the bucket, and on a very large bucket the scan stops early, so each then carries a trailing plus sign, the file browser footer adds the same plus sign after its total, and a note under the cards says the totals are at least this much. It then shows CDN delivery over a period you pick from Last day, Last week, Last month, Last 3 months or Last year: Bandwidth, Requests and Cache hit rate, with a requests over time chart. Before the CDN has served anything the chart reads No traffic, "Requests appear once the CDN serves files."

CMS uploads land here too

Image and file fields in the CMS upload into this same storage, under a CMS/ prefix organised by collection and row. They appear in the file browser like anything else, and they always upload as public, because the CMS embeds them in your pages. Making one private from the file browser breaks the field that shows it.

From an AI client over MCP

The files in your bucket are also reachable from a connected AI client. list_storage_files, read_storage_file, get_storage_stats, get_storage_info and get_storage_file_url cover browsing and reading; upload_storage_file, create_storage_folder, move_storage_files, delete_storage_files, delete_storage_folder and clear_storage cover the writes; set_storage_visibility and set_storage_root_visibility cover what this page calls public and private, and get_storage_info reports the CDN link and the top level default. The archives described under Backups below have tools of their own.

The private storage key sits behind its own credentials:reveal scope, unticked by default, because get_storage_key and rotate_storage_key return it in plain text into the client's transcript. See MCP tools and Tokens and scopes.

Backups

On any paid plan the whole bucket is archived once a day, and you can archive it yourself up to ten times in twenty four hours with Back up now, which asks you to confirm and says how many manual backups that leaves. The archives live under the Storage tab of Backups, are downloadable as a .zip, and are kept outside your bucket so they never count toward what it holds. A day where nothing changed, or a bucket with no files, is passed over by the daily run.

Downloading happens in that tab only. Chat and a connected MCP client can list the archives, take one, delete one or all of them and turn daily backups on or off, with list_storage_backups, create_storage_backup, delete_storage_backup, clear_storage_backups and set_storage_backup_schedule over MCP, but neither can download or restore one. See Backups from chat and MCP.

An archive holds the bucket as it stands when it actually starts reading it, which can be a few minutes after you ask for one while other archives finish, so wait for the row to show its file count before you delete, move or hide anything you meant that backup to hold.

There is no restore for a storage backup. To get lost files back, download an archive that still holds them and upload the files again from the Storage sub-tab. Backups walks through it.

The Configuration sub-tab has a Backups section with two rows. Storage backups has a View backups button that opens that tab directly. Daily backups is a switch, on for every site until someone turns it off, and turning it off stops only the daily archive of this site: Back up now still works and every archive already taken stays. On the free plan both rows carry a Paid badge and the switch is disabled, while the button still works, so archives taken while on a paid plan stay reachable. See Turn daily storage backups off.

Delete all storage backups under Danger zone in the same sub-tab removes every finished archive at once, and needs the Delete projects permission. Clearing storage does not delete any archive.

Next