Files and assets

Where each kind of file belongs: public media on the CDN, private generated files as artifacts, data in the database, and source in the project tree — plus why the project tree is source-only.

The Spikefrost Team30 Jul 20264 min read

Four kinds of file, four homes. Getting this right the first time avoids a cleanup later.

What it is Where it goes How you reach it
Site media — images, video, fonts, downloads Assets (R2 + CDN) /assets/... URL
Data the app queries — records, catalogs, content Database tables SQL
Generated private files — reports, exports Artifacts Team-only durable link
Source code Project tree The repo you edit

Public assets

Assets are served from your app's own domain under /assets/, backed by object storage and a CDN.

sf assets upload ./hero.png --path img/hero.png
# → https://your-app.example.com/assets/img/hero.png

The --path is the exact key under assets/, so you control the URL layout. Reference it like any other URL:

<img src="/assets/img/hero.png" alt="" class="w-full rounded-2xl" />

Two properties to rely on:

  • Automatically cached at the edge. No Cache-Control work, no configuration.
  • Immutable, keyed per deploy version. An asset is never served stale — and consequently, replacing a file means uploading to a new path and updating the reference, not overwriting the same key.

Private artifacts

For files that are generated and must not be public — an analysis document, a filled PDF, a data export:

sf artifacts upload ./report.md
# → { filename, download_url, s3_key }   team-only, durable
sf artifacts download <url> ./local-copy.md

Use these instead of committing a generated report into the tree or making it a public asset. Add --temporary for something short-lived.

Handling uploads from users

Uploads land in the app's storage bucket, bound as c.env.ASSETS:

app.post('/api/upload', async (c) => {
  const body = await c.req.parseBody();
  const file = body['file'];
  if (!(file instanceof File)) return c.json({ error: 'file required' }, 400);

  const key = `uploads/${crypto.randomUUID()}-${file.name}`;
  await c.env.ASSETS.put(key, file.stream(), {
    httpMetadata: { contentType: file.type },
  });

  return c.json({ ok: true, url: `/assets/${key}` });
});

Validate before you store: check the content type against an allowlist, cap the size, and never trust the client-supplied filename as a storage key — generate one. Anything written under the public assets prefix is public; user files that shouldn't be world-readable need their own key prefix and a route that authorizes the reader instead of a direct URL.

The project tree is source only

Your app's file storage holds source code, with a budget of 256 MB per app. Everything in it is versioned and synced to every checkout, so heavy files make every pull slower for everyone, forever.

What that means concretely:

  • Media → assets. Never keep multi-megabyte images or video in the tree.
  • Data the app queries → database tables. Reading a large JSON or CSV out of the tree at runtime is a design bug; model it as tables.
  • Datasets to process → fetch the file, extract what you need into the database, then delete the download. A downloaded file counts against the budget while it sits there.
  • Generated output → assets (public) or artifacts (private). Don't accumulate exports in the tree.
  • Backups, archives, zips → never. Every file is already versioned with full history and point-in-time recovery, so a zip backup is redundant and expensive.

Calling platform capabilities from Worker code

Related, because it's how a route produces a file or an email without an agent. Worker code can call platform capabilities directly through a helper in app/src/utils/spikefrost.ts:

await callSpikefrost(c.env, 'builtin#notify_user', {
  to: 'user@example.com',
  subject: 'Your verification code',
  message: 'Your code is 123456',
});

That sends from the platform's system address — correct for OTPs, receipts, and alerts. Branded, threaded, conversational email is a different job that belongs to an agent owning an email connector.

Same mechanism reaches image, PDF, and chart generation, and integrations your team has configured (gmail#send_email and friends). Browse what's available with sf find_tools --query "...".

Two rules: don't hand-roll a provider client or ask users for API keys — the platform holds credentials — and put AI text generation in an agent, not in a route, so its usage is metered, budget-gated, and visible in the app's observability.

Outbound calls from a fixed IP

Workers egress from shared, changing IP ranges, so a third party that requires IP allowlisting — a bank, a payment provider, an ERP, a corporate firewall — can never allowlist your Worker directly. Route those calls through the platform proxy with the scaffold's fetchWithStaticIp(env, url, options) helper; requests then originate from a static egress address that doesn't change. The address to hand the third party is 34.243.173.185.

Ordinary public APIs don't need this — plain fetch() is faster.

Next

Frequently asked questions

How do I add an image to my site?

Upload it with sf assets upload ./hero.png --path img/hero.png and reference the returned /assets/img/hero.png URL. Don't commit multi-megabyte media into the project tree.

Why can't I just keep files in the project folder?

The project tree is source, with a 256 MB budget per app, and every file in it is versioned and synced on every pull. Media and datasets there make checkouts slow and burn the budget for no benefit — the CDN serves them better anyway.

Are assets cached?

Yes, automatically, and they're immutable-keyed per deploy version — so they're never served stale. There's no cache configuration to do.

How do I replace an asset?

Upload to a new path and update the reference. Assets are immutable, so overwriting the same key is not the way to ship a changed file.

What's the difference between an asset and an artifact?

An asset is public and served from your site's /assets/ URL. An artifact is private, reachable only by your team through a durable link — the right place for a generated report or export that must not be public.