Skip to Content
We are live but in Staging 🎉
Data EnginesObjectsRecipesStatic Site / Assets

Static site / public asset hosting

Goal: serve assets directly from K3 over plain GET URLs — no presigning, no auth headers — for things like images, fonts, downloads, or a static-site build.

Why: presigning every URL is overkill for assets meant to be public. K3 supports a per-bucket access_mode that lets anonymous reads through while keeping writes locked to the bucket owner.

Two hosts, two jobs. You upload over the signed S3 wire object.uk-lon-1.dodil.io, and readers fetch over the control-plane route api.data.dodil.io/orgs/<org>/…. Only the second one serves anonymous requests: the object endpoint refuses any request without a credential with 401 AccessDenied before it looks at the bucket, and has no /orgs/ route. Per-bucket CORS is evaluated on the control-plane route too.

Not a CDN. K3 doesn’t do edge caching. For high-traffic public content, put a CDN (Cloudflare, Fastly, CloudFront) in front of the K3 endpoint — the recipe below is the origin.

1. Create a public bucket

bucket create has no --access-mode flag, so a new bucket starts PRIVATE and you flip it:

dodil data bucket create assets-public -d "Public assets" dodil data bucket update assets-public --access-mode public # ...or flip an existing bucket dodil data bucket update kb-prod --access-mode public

The three access modes (from Core Concepts → Bucket):

ModeAnonymous readAnonymous writeOwner read/write
private (default)
public
customgoverned by Bucket Policy — no policy = deniedgoverned by Bucket Policy✅ (owner always has full access)

Once public, any GET to https://api.data.dodil.io/orgs/<org>/<bucket>/<key> succeeds without an Authorization header. <org> is your org slug.

2. Configure CORS (if assets are loaded cross-origin)

Browsers enforce CORS on cross-origin fetches for fonts, JSON, ES modules, and any fetch()-loaded resource. <img> / <video> tags don’t need CORS unless you read pixel data via canvas.

cat > cors.json <<'EOF' { "corsRules": [ { "allowedOrigins": ["*"], "allowedMethods": ["GET", "HEAD"], "allowedHeaders": ["*"], "exposeHeaders": ["ETag", "Content-Length", "Content-Type"], "maxAgeSeconds": 86400 } ] } EOF dodil data bucket cors set assets-public -f cors.json

Lock allowedOrigins to your real domains in production.

3. Upload your assets

Assumes an AWS_PROFILE / AWS_ENDPOINT_URL=https://object.uk-lon-1.dodil.io set up as in S3 Compatibility; otherwise add --endpoint-url to each command.

# One-shot: copy a built site aws s3 sync ./dist s3://assets-public/site/ --delete # With proper content-type / cache headers — important for static sites aws s3 sync ./dist s3://assets-public/site/ \ --cache-control "public, max-age=31536000, immutable" \ --exclude "*.html" \ --delete # Re-sync HTMLs with short TTL aws s3 sync ./dist s3://assets-public/site/ \ --cache-control "public, max-age=60" \ --exclude "*" --include "*.html"

aws s3 sync is the right tool — it computes diffs against the destination and only uploads changed objects.

4. Reference assets in your app

<!-- Image --> <img src="https://api.data.dodil.io/orgs/<org>/assets-public/site/logo.svg" alt="logo" /> <!-- Download link --> <a href="https://api.data.dodil.io/orgs/<org>/assets-public/site/whitepaper.pdf">Download whitepaper</a> <!-- Stylesheet --> <link rel="stylesheet" href="https://api.data.dodil.io/orgs/<org>/assets-public/site/app.css" /> <!-- ES module --> <script type="module" src="https://api.data.dodil.io/orgs/<org>/assets-public/site/app.js"></script>

URLs are stable: https://api.data.dodil.io/orgs/<org>/<bucket>/<key>.

Cache-control matters

S3 (and therefore K3) returns the Cache-Control header you set on the object at upload time. Get this right or you’ll either ship stale builds or DDoS your origin:

File typeRecommended Cache-ControlWhy
.htmlpublic, max-age=60 (or no-cache)Entry points — needs to pick up new bundle hashes
Fingerprinted assets (app.abc123.js)public, max-age=31536000, immutableContent-addressed — never changes
Images, fonts, downloads (stable filenames)public, max-age=86400 (1 day)Balance
Auto-generated previews / thumbnailspublic, max-age=3600Short enough to invalidate by re-upload

aws s3 sync --cache-control sets the header at upload time. After upload, dodil data object show <key> -b <bucket> -o json confirms what was stored.

Putting a CDN in front

K3 is the origin, not the edge. For high-traffic public content, add a CDN:

The origin is the control-plane host — a CDN fetches anonymously, so pointing it at object.uk-lon-1.dodil.io gets you a wall of 401s.

Cloudflare:

  • DNS: assets.example.com → CNAME → <some-cloudflare-cname>
  • Cloudflare origin: https://api.data.dodil.io, with an origin rule prefixing /orgs/<org>/assets-public onto the path
  • Use the path-style URL — K3 requires it
  • Set Cloudflare cache rules: respect origin Cache-Control

CloudFront:

  • Origin: api.data.dodil.io
  • Origin path: /orgs/<org>/assets-public (so CF requests /foo from K3 land at assets-public/foo)
  • Cache policy: CachingOptimized (respects origin headers)
  • Leave origin request signing off — the bucket’s PUBLIC access mode is what authorizes the fetch

Limitations vs. S3-website / R2-Custom-Domains

K3 does not currently implement:

  • Website endpoint (?website bucket config — index documents, error documents, redirect rules). Route / to index.html at the CDN layer or in your app.
  • Custom domains on the K3 endpoint directly. Use a CDN as the public name and K3 as the origin.

Both are roadmap, not in production today.

Common gotchas

SymptomCauseFix
401 AccessDenied on every asset URLAssets are served from object.uk-lon-1.dodil.io, which demands a credential on every requestServe from https://api.data.dodil.io/orgs/<org>/…
403 AccessDenied on anonymous GETBucket isn’t public (or is custom with no policy)dodil data bucket update <name> --access-mode public
CSS / JS loaded but fonts blockedCORS rule too narrow, or the request went to the object endpoint (which ignores per-bucket CORS)Add GET and HEAD to allowedMethods, set exposeHeaders, and fetch over api.data.dodil.io
Browser caches stale HTML after deployHTML uploaded with long max-ageRe-upload with Cache-Control: max-age=60
404 on subdirectory paths (/site/about/)K3 doesn’t auto-resolve index.htmlHandle index-document mapping at the CDN or in your app
Everything under a metadata/ or search/ key prefix 404s anonymouslyobjects, policy, sources, rules, ingest, search, metadata, indexes, vector-store are reserved first path segments on the control-plane routeDon’t use them as top-level key prefixes
HTML downloads as application/octet-streamContent-Type wasn’t set at upload — dodil data object create never sends oneaws s3 sync infers from extension; otherwise pass --content-type "text/html"

See also