By Octave · drafted with AI, tested on Hubfly space. Originally published on the Hubfly space blog.
TL;DR
- What you get: an upload flow where every replaced or deleted file keeps its earlier copy, plus the version ID you need to bring it back.
- Who it's for: developers whose users upload contracts, invoices, photos or exports into their app.
- Time needed: about 20 minutes.
- Cost: old copies are billed like any stored file, about $0.011 per GB a month.
A customer uploads contract.txt for the second time, and the new one is the wrong file. Or a support agent deletes a folder that was still needed. Without versioning, the original is gone, and the support ticket ends with "sorry".
This guide sets up a Hubfly space Storage bucket that keeps every earlier copy, so you can bring it back instead of apologising. You'll enable versioning, upload from the browser through short-lived upload links, and save the version ID each upload returns. That last step is what makes recovery easy.
Try it on Hubfly space → Create a free account, no card needed.
How versioning works, in one paragraph
A bucket stores files by key, such as tenant-001/contract.txt. With versioning off, uploading to the same key replaces the file for good. With versioning Enabled, the old copy stays in the bucket as an older version, and the new one becomes the current version. Deleting a file adds a delete marker instead of erasing the data. Your app only sees the current version, but the older ones are still there to read.
Step 1: Create a bucket and turn on versioning
- In the dashboard, click Create Project, pick Storage, choose the region and click Create Project.
- Click New bucket, name it (for example
customer-uploads) and click Create bucket. - Open the bucket, click Settings, set Object versioning to Enabled, then click Save settings.
Bucket settings: versioning, image cache retention, and the delete rule for buckets.
Do this before the first upload. Versioning only protects files written after it's on.
One-way switch. Once a bucket's versioning is enabled, you can set it to Suspended later but not back to Disabled. Suspended stops keeping new versions; the old ones stay. From a terminal, the same setting is hubfly storage buckets update customer-uploads --versioning enabled.
Step 2: Let the browser upload through a short-lived link
Browsers shouldn't hold storage secrets, and presigned S3 uploads from a browser don't work on Hubfly space (the S3 endpoint rejects the cross-origin check browsers send first). Instead, your server asks for an upload session, and the browser sends the file straight to the link it gets back.
First, create a storage API key: open the project's API access tab and click Create API key. Limit it to the customer-uploads bucket. The key starts with hfs_ and is shown once, so put it straight into your server's secrets as HUBFLY_STORAGE_KEY. It's limited to Storage upload sessions, so it can't manage the project, its keys or its settings.
Then add a small endpoint to your server (Node.js 18+, Express):
import path from "node:path"; app.use(express.json()); const ALLOWED = new Set(["application/pdf", "image/png", "image/jpeg", "text/plain"]); function cleanName(name) { const base = path.basename(String(name || "")).replace(/[^\w.\- ]/g, "_").slice(0, 200); if (!base || base === "." || base === "..") throw new Error("bad file name"); return base; } // POST /uploads/start { fileName, contentType } app.post("/uploads/start", requireLogin, async (req, res) => { if (!ALLOWED.has(req.body.contentType)) return res.status(400).json({ error: "file type not allowed" }); let name; try { name = cleanName(req.body.fileName); } catch { return res.status(400).json({ error: "bad file name" }); } const objectKey = `${req.user.tenantId}/${name}`; // e.g. "tenant-001/contract.txt" const r = await fetch( `https://api.hubfly.space/api/v1/projects/${process.env.HUBFLY_PROJECT_ID}/storage/objects/upload-session`, { method: "POST", headers: { Authorization: `Bearer ${process.env.HUBFLY_STORAGE_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ bucket: "customer-uploads", objectKey, contentType: req.body.contentType, }), } ); if (!r.ok) return res.status(502).json({ error: "upload link failed" }); const { data } = await r.json(); // { upload_url, object_key, expires_at, content_type } res.json({ uploadUrl: data.upload_url, objectKey: data.object_key }); }); The key is built on your server from the logged-in user, and the file name is cleaned first, so one customer can't write into another customer's folder. The content type is checked against a short allowlist, so nobody stores an HTML page in your bucket. The link works for one bucket and one key, and expires after an hour. Upload sessions take no size limit, so cap the file size in your app before you ask for a link.
Step 3: Upload, and keep the version ID
In the browser, send the file to the link. The response tells you which version you just created:
const start = await fetch("/uploads/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileName: file.name, contentType: file.type }), }).then(r => r.json()); const put = await fetch(start.uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!put.ok) throw new Error("upload failed or link expired"); const result = await put.json(); // 201 → { "object_key": "tenant-001/contract.txt", "version_id": "ver_5f96…", "size": 44 } await fetch("/uploads/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(result), }); On the server, save a row per upload, but only for keys inside the user's own folder:
app.post("/uploads/finish", requireLogin, async (req, res) => { const { object_key, version_id, size } = req.body; if (!String(object_key).startsWith(`${req.user.tenantId}/`) || !String(version_id).startsWith("ver_")) return res.status(403).end(); await db.fileVersions.insert({ object_key, version_id, size, user_id: req.user.id, event: "upload", at: new Date() }); res.status(204).end(); }); Asking for the link takes two to three seconds, and the upload itself under a second for small files. When your app deletes a file, record that too. The Storage delete call returns { "delete_marker": true, "version_id": "ver_…" }; that ID is the marker itself, not a copy to restore. The last upload row before it is the version to bring back.
Why save the version ID? The dashboard and the Storage object list show only the current version of each key. Neither has a "show old versions" list. Your own upload history is the simplest index of every version you might need to restore.
You can check any file's current version in the dashboard: open the bucket, switch to Raw, and click the info icon on a file.
Object details for the current version of tenant-001/contract.txt.
The Raw view shows one card per key, the current version only. Files added with the dashboard's Upload button get their own uploads/<id>/ key, so they never replace each other.
Step 4: Restore an earlier version
The dashboard's download button always returns the current version, so restoring goes through the S3 endpoint. Create an S3 access key in the project's Access keys tab (read/write, scoped to customer-uploads) and keep it on your server only.
Hubfly space's S3 endpoint lets an object GET address an older version with versionId. To restore, look up the version ID in your upload history, read that version, and write it back to the same key, which makes it the current version again. Before you do, check that the key starts with the requesting customer's folder, the same way /uploads/finish does.
What it costs
Every version is a stored file, billed at the Hubfly space Storage rate of $0.00001536 per GB-hour:
- 1 GB for a month: 1 × 0.00001536 × 730 = $0.0112.
- 10 GB of current files: 10 × 0.00001536 × 730 = $0.11 a month.
- The same 10 GB where each file was replaced twice (30 GB kept): 30 × 0.00001536 × 730 = $0.34 a month.
Versioning, upload sessions and version IDs are free. Old versions count toward the project's storage quota (100 GB by default), so check Storage used on the project page now and then.
What can go wrong
- "I turned it on, but the file from yesterday is gone." Versioning protects only writes made after it was enabled. Turn it on when you create the bucket.
- Switching versioning back to Disabled is rejected (the API returns a 400 error). Once enabled, a bucket can only be Suspended. Choose Suspended to stop keeping new versions.
- Files re-uploaded with the dashboard's Upload files button don't create versions. Each dashboard upload gets its own
uploads/<id>/key, so nothing is replaced. Versions build up only when your app writes to the same fixed key, as in Step 2.
Limits, honestly
- No version list in the dashboard or the Storage object list, so you need your own record of version IDs. Restoring goes through the S3 endpoint.
- No automatic expiry for old versions is documented yet. If files change often, budget for the extra storage.
- A normal delete only adds a delete marker; every earlier version stays stored and billed. To erase a customer's file for good (for example on a deletion request), delete each version by its version ID from your history.
- A bucket must be empty, including retained versions, before you can delete it.
- Files are stored without encryption at rest. Encrypt sensitive documents in your app before upload.
- Once enabled, versioning can only be suspended, not turned off.
Who this is not for
If you need legal-hold or write-once storage (compliance archives that nobody, not even an admin, can delete), versioning isn't that, and no object lock is documented on Hubfly space. Use a provider with object lock for that case. If your files never change after upload, give each upload a unique key instead and skip versioning.
Read next: How to resize and watermark product photos with just a URL · How to get a managed PostgreSQL database for about $2 a month
Go further on Hubfly space
- Hand out temporary download links for files in the same bucket.
- Write S3 keys into your app as secrets with
hubfly storage keys create … --to <cell-project>, which stores the secret as a Cell project variable. - Read the Storage limits and security notes before you put customer files in production.
Start free, 360 hours included. Create your Hubfly space account →



