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.html rather 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

OptionIndex/error docsCustom domain + TLSBest for
Cloudflare R2 + PagesYes, via PagesIncludedSites with zero-egress economics and a Git deploy
Amazon S3 website endpoint + CloudFrontYesVia CloudFrontTeams already inside AWS
Google Cloud Storage website configYesVia load balancerGCP-native stacks
Azure Blob static websitesYesVia CDNAzure-native stacks
MinIO on your own box + nginx or a CDNYou configure itYou configure itOn-prem or air-gapped sites
Infrai storageNoNoNot 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.

References

Browse more storage developer guides