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 routeapi.data.dodil.io/orgs/<org>/…. Only the second one serves anonymous requests: the object endpoint refuses any request without a credential with401 AccessDeniedbefore 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 publicThe three access modes (from Core Concepts → Bucket):
| Mode | Anonymous read | Anonymous write | Owner read/write |
|---|---|---|---|
private (default) | ❌ | ❌ | ✅ |
public | ✅ | ❌ | ✅ |
custom | governed by Bucket Policy — no policy = denied | governed 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.jsonLock allowedOrigins to your real domains in production.
3. Upload your assets
aws s3 sync
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 type | Recommended Cache-Control | Why |
|---|---|---|
.html | public, max-age=60 (or no-cache) | Entry points — needs to pick up new bundle hashes |
Fingerprinted assets (app.abc123.js) | public, max-age=31536000, immutable | Content-addressed — never changes |
| Images, fonts, downloads (stable filenames) | public, max-age=86400 (1 day) | Balance |
| Auto-generated previews / thumbnails | public, max-age=3600 | Short 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-publiconto 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/foofrom K3 land atassets-public/foo) - Cache policy:
CachingOptimized(respects origin headers) - Leave origin request signing off — the bucket’s
PUBLICaccess mode is what authorizes the fetch
Limitations vs. S3-website / R2-Custom-Domains
K3 does not currently implement:
- Website endpoint (
?websitebucket config — index documents, error documents, redirect rules). Route/toindex.htmlat 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
| Symptom | Cause | Fix |
|---|---|---|
401 AccessDenied on every asset URL | Assets are served from object.uk-lon-1.dodil.io, which demands a credential on every request | Serve from https://api.data.dodil.io/orgs/<org>/… |
403 AccessDenied on anonymous GET | Bucket isn’t public (or is custom with no policy) | dodil data bucket update <name> --access-mode public |
| CSS / JS loaded but fonts blocked | CORS 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 deploy | HTML uploaded with long max-age | Re-upload with Cache-Control: max-age=60 |
404 on subdirectory paths (/site/about/) | K3 doesn’t auto-resolve index.html | Handle index-document mapping at the CDN or in your app |
Everything under a metadata/ or search/ key prefix 404s anonymously | objects, policy, sources, rules, ingest, search, metadata, indexes, vector-store are reserved first path segments on the control-plane route | Don’t use them as top-level key prefixes |
HTML downloads as application/octet-stream | Content-Type wasn’t set at upload — dodil data object create never sends one | aws s3 sync infers from extension; otherwise pass --content-type "text/html" |
See also
- Browser Upload — for user-uploaded public content
- Mirror from AWS S3 — moving existing public assets into K3
- CORS — API Reference — full CORS rule shape
- Bucket — Core Concepts — access modes