Serving a static site straight from an object bucket: what breaks
Bucket website hosting needs an index document, MIME handling, a custom domain and CORS. Here's what an object API gives you, and what Infrai refuses outright.
Sometimes, and not here. A bucket with website hosting — Amazon S3’s website endpoint, Google Cloud Storage, Azure Blob’s static website feature — will serve index.html and a 404 page from a prefix, and paired with a CDN that’s a legitimate way to run a site with no web server. Infrai’s storage API is not one of those buckets: it refuses HTML and JavaScript uploads outright with STORAGE_CONTENT_TYPE_BLOCKED, has no website endpoint and no index-document behaviour, and browsers can’t reach the object host cross-origin, so the answer for this API specifically is a flat no.
That’s worth knowing in one call rather than three hours in. If you’re here to host a site, skip to the table below and pick one of the real options; if you’re here because your app also produces files that a static site references, the second half is the part that pays.
The upload the API rejects
Try it and the refusal is immediate and specific:
export INFRAI_API_KEY="your_infrai_api_key"
BODY=$(python3 -c 'import base64, json; print(json.dumps({"data_base64": base64.b64encode(open("index.html","rb").read()).decode(), "content_type": "text/html"}))')
curl -sS -X PUT "https://api.infrai.cc/v1/storage/object/put/kb-site-0726/index.html" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d "$BODY"
{
"ok": false,
"error": {
"code": "STORAGE_CONTENT_TYPE_BLOCKED",
"http_status": 415,
"message": "content-type 'text/html' is not allowed (active/executable)",
"retryable": false
}
}
text/javascript, application/javascript and application/xhtml+xml come back the same way. Passive types are fine — CSS, PNG, SVG, WOFF2, WASM, JSON and plain text all upload without complaint — which tells you the policy exactly: this is an asset store that declines to host executable documents, on purpose, because a bucket serving arbitrary user HTML from a shared hostname is a phishing kit waiting to happen.
What bucket hosting needs that an object API doesn’t have
Even where uploads are allowed, “just dump the files in” quietly assumes six behaviours:
- an index document, so
/docs/serves/docs/index.htmlrather than a directory listing or a 404; - an error document, so a client-side router doesn’t hand users raw XML;
- content types stored per object, or every file downloads instead of rendering;
- a custom domain with TLS, which on most stores means a CDN in front, not the bucket itself;
- CORS rules, the moment a font or an XHR call crosses an origin;
- cache headers, or you pay for every reload and can’t invalidate a bad deploy.
On this API the first two don’t exist — a request to a bucket prefix root returns a 404, not an index — and the cross-origin story is the sharpest edge of the lot. There is a route for the rules, POST /v1/storage/bucket/set_cors/{bucket}, and the bucket record keeps what you send it. The preflight is the part that hasn’t landed: OPTIONS against a signed URL, with an Origin and an Access-Control-Request-Method, answers 403 and carries no Access-Control-Allow-* header, so a browser won’t proceed. That rules out the XHR half of a static app even if the HTML lived somewhere else.
Where to actually host it
| Option | Index/error docs | Custom domain + TLS | Best for |
|---|---|---|---|
| Cloudflare R2 + Pages | Yes, via Pages | Included | Sites with zero-egress economics and a Git deploy |
| Amazon S3 website endpoint + CloudFront | Yes | Via CloudFront | Teams already inside AWS |
| Google Cloud Storage website config | Yes | Via load balancer | GCP-native stacks |
| Azure Blob static websites | Yes | Via CDN | Azure-native stacks |
| MinIO on your own box + nginx or a CDN | You configure it | You configure it | On-prem or air-gapped sites |
| Infrai storage | No | No | Not this; use it for the assets a site links to |
For a plain marketing or docs site, Cloudflare Pages is the least work of any of these — a repo, a build command, done. That’s not a grudging admission; it’s the right recommendation, and no object API competes with it on this job.
What the bucket is genuinely good for here
Build artefacts and downloadable assets, which is a real part of a static-site workflow. Publish the tarball your CI produced, keep the last N releases, hand a deploy target a short-lived link:
import { readFile } from "node:fs/promises";
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "kb-site-0726";
const sha = process.env.GIT_SHA ?? "unknown";
const key = `releases/${new Date().toISOString().slice(0, 10)}/site-dist.tar.gz`;
const bytes = await readFile("dist.tar.gz");
const payload = {
data_base64: bytes.toString("base64"),
content_type: "application/gzip",
metadata: { git_sha: sha },
};
const put = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!put.ok) throw new Error(`upload failed: ${put.status} ${await put.text()}`);
const stored = (await put.json()).data;
console.log(`stored ${stored.key} (${stored.size_bytes} bytes, etag ${stored.etag})`);
Metadata keys are normalised on the way in: send git_sha and the object comes back carrying git-sha. Worth knowing before you write a lookup against the exact key you sent, because head and list will both report the hyphenated form.
Confirm what landed, without paying for a download:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-site-0726/releases/2026-07-26/site-dist.tar.gz" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "releases/2026-07-26/site-dist.tar.gz",
"size_bytes": 12,
"etag": "623eaa3daf5d08b15c263ab39cbb8e64",
"content_type": "application/gzip",
"metadata": { "git-sha": "a1b2c3d" },
"last_modified": "2026-07-26T00:56:27Z"
}
}
And to see the whole shelf of releases, a free listing:
curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-site-0726?prefix=releases/&limit=50" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
One security caveat before you point a site at these URLs
Objects in a private bucket answer only to a signed URL: strip the query string off a presigned link and the storage host returns 403. That makes the TTL you choose the real control, which is the property you want for a download link on a public page — mint it short, mint it per click, and let it die. What it doesn’t give you is per-user authorisation, so if a site needs assets that only a logged-in reader may fetch, put your own handler in front and check the session before you mint anything.
The region field is checked rather than recorded — ask for a region the vendor isn’t provisioned in and bucket/create refuses with a 400 naming the one it does serve, so what a bucket reports is where it lives.
What this costs
Free, mostly: bucket create, listing, head, presign and lifecycle rules are free and rate-limited. Writes are billable per call — verified 27 July 2026 at $0.0001 per object/put — so a 40-file asset drop costs well under a cent in call charges, plus stored bytes.
Reads are the line item that tracks traffic rather than request count. object/get is metered on the bytes it actually returns, $0.104 per GB on the same reading, so what a fetch costs is a function of file size — a 4 KB stylesheet and a 40 MB video are four orders of magnitude apart on the same route. That’s the arithmetic reason a public site’s assets belong behind a CDN rather than in front of a metered egress path, and it’s also why head and list being free matters: you can check what’s there as often as you like and only pay when bytes move. Read today’s rates rather than trusting a page:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd', 0), c['billing'].get('unit')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Print the unit alongside the figure, as that command does — the units are not all the same, and a number read without one is how a cost model goes wrong. New accounts get $2 of trial credit.
The honest summary: host the site on a static host built for it, and keep the artefacts, exports and user files it links to here. The follow-on work is already on the same account — POST /v1/cron/create to rebuild the release listing nightly, POST /v1/queue/publish to fan out a post-deploy job, POST /v1/errors/capture when the upload fails at 3 a.m. — with no second vendor to onboard and no second invoice to reconcile.