Skip to Content
We are live but in Staging 🎉
Object StorageRecipesBrowser Upload

Direct-from-browser upload

Goal: let a signed-in user upload a file from their browser directly to K3 — no bytes flowing through your application server.

Why: your server stays stateless, the upload runs at the network’s full bandwidth, and you don’t pay for inbound bandwidth twice.

Shape:

[browser] --(1) GET /api/upload-url--------------> [your backend] [browser] <--(2) { url, key, expiresAt }--------- [your backend] [browser] --(3) PUT <url> + file body-----------> [k3 gateway]

The presigned URL is single-use, time-limited, and scoped to one bucket+key+method. Your backend never sees the file.

1. Configure CORS on the bucket

The browser will issue a cross-origin PUT and read response headers (etag). The bucket needs a CORS rule that allows it. Do this once per bucket:

cat > cors.json <<'EOF' { "corsRules": [ { "allowedOrigins": ["https://app.example.com"], "allowedMethods": ["PUT", "GET", "HEAD"], "allowedHeaders": ["*"], "exposeHeaders": ["ETag"], "maxAgeSeconds": 3600 } ] } EOF dodil k3 bucket cors set kb-prod -f cors.json

Adjust allowedOrigins to match your frontend origin. Use ["*"] only for local development. See CORS — API Reference for the full shape.

2. Mint a presigned PUT URL on the backend

You have two paths, both supported by K3:

When to use
SigV4 presign via the S3 SDKYou already have S3 SDK creds; works with every S3 client
K3 presign via GetObjectUrlShorter URLs, but read-only — won’t work for upload (PUT)

For uploads you need SigV4 presign, because GetObjectUrl issues GET-only URLs. Implementation:

// backend route: GET /api/upload-url?filename=foo.pdf import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { randomUUID } from "node:crypto"; const s3 = new S3Client({ endpoint: process.env.K3_ENDPOINT, // https://k3.dev.dodil.io region: "us-east-1", forcePathStyle: true, credentials: { accessKeyId: process.env.DODIL_SA_ID!, secretAccessKey: process.env.DODIL_SA_SECRET!, }, }); export async function uploadUrlHandler(req, res) { const { filename, contentType } = req.query; // Namespace under the user, never trust the raw filename const key = `uploads/${req.user.id}/${randomUUID()}/${filename}`; const url = await getSignedUrl( s3, new PutObjectCommand({ Bucket: "kb-prod", Key: key, ContentType: contentType, }), { expiresIn: 300 } // 5 minutes — short-lived ); res.json({ url, key, expiresAt: Date.now() + 300_000 }); }

3. Upload from the browser

<input id="file" type="file" /> <button id="upload">Upload</button> <script type="module"> document.getElementById("upload").onclick = async () => { const file = document.getElementById("file").files[0]; // 1. Ask backend for a presigned URL const r = await fetch( `/api/upload-url?filename=${encodeURIComponent(file.name)}` + `&contentType=${encodeURIComponent(file.type)}` ); const { url, key } = await r.json(); // 2. PUT directly to K3 — no server in the path const put = await fetch(url, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!put.ok) throw new Error(`upload failed: ${put.status}`); // 3. Tell your backend the upload finished so it can record metadata await fetch("/api/upload-complete", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ key, etag: put.headers.get("etag") }), }); }; </script>

Showing upload progress

fetch can’t report PUT progress — use XMLHttpRequest instead:

function uploadWithProgress(url, file, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", url); xhr.setRequestHeader("Content-Type", file.type); xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); }; xhr.onload = () => (xhr.status === 200 ? resolve(xhr) : reject(xhr)); xhr.onerror = () => reject(xhr); xhr.send(file); }); }

What happens after a successful upload

Once the PUT returns 200, K3’s S3 gateway:

  1. Persists the object to durable storage.
  2. Fires ingest discovery — if kb-prod has matching pipeline rules, the object is automatically routed to vector / warehouse / Scriptum pipelines (see Pipelines). You can check ObjectInfo.pipelineStatuses for per-rule progress.
  3. Returns the ETag header — the SHA-1 / MD5 of the object body, useful for client-side integrity checks.

Your frontend doesn’t need to wait for ingest to complete — pipelines run async and your code can poll GetObjectInfo if it needs to know when indexing finishes.

Common gotchas

SymptomCauseFix
Browser blocks PUT with CORS errorBucket has no CORS, or origin doesn’t matchSet CORS as in step 1; verify with dodil k3 bucket cors get kb-prod
403 SignatureDoesNotMatchFrontend sets headers the URL wasn’t signed forOnly set Content-Type and only if it was in the PutObjectCommand params
403 AccessDenied from quotaOrg’s storage entitlement is fullCheck your org’s quota or contact admin
URL expires before upload finishesexpiresIn too short for large filesUse multipart for files > 100 MB — each part has its own URL
ETag header missing in responseBrowser CORS strips response headers by defaultInclude "ETag" in exposeHeaders of the CORS rule (already done in step 1)

Security notes

  • Always namespace keys server-side (e.g. uploads/<userId>/<uuid>/...). Never let the client choose the key — it’s a path-traversal risk.
  • Keep expiresIn short (60–300 s). The URL is a bearer token for one PUT.
  • Optionally bind to content type / size — pass ContentType (and consider ContentLength enforcement at presign time) so a client can’t lie about what they’re uploading.
  • Don’t rely on Content-Type the browser sends for security decisions — sniff server-side if you care.

See also