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.
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
# Either create it public from the start...
dodil k3 bucket create assets-public -d "Public assets"
dodil k3 bucket update assets-public --access-mode public
# ...or flip an existing bucket
dodil k3 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 | governed by Bucket Policy | ✅ (owner always has full access) |
Once public, any GET to https://k3.dev.dodil.io/<bucket>/<key> succeeds without an Authorization header.
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 k3 bucket cors set assets-public -f cors.jsonLock allowedOrigins to your real domains in production.
3. Upload your assets
aws s3 sync
# 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://k3.dodil.io/assets-public/site/logo.svg" alt="logo" />
<!-- Download link -->
<a href="https://k3.dodil.io/assets-public/site/whitepaper.pdf">Download whitepaper</a>
<!-- Stylesheet -->
<link rel="stylesheet" href="https://k3.dodil.io/assets-public/site/app.css" />
<!-- ES module -->
<script type="module" src="https://k3.dodil.io/assets-public/site/app.js"></script>URLs are stable: https://<endpoint>/<bucket>/<key>. Move endpoints between staging and prod by changing the host.
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 k3 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:
Cloudflare:
- DNS:
assets.example.com→ CNAME →<some-cloudflare-cname> - Cloudflare origin:
https://k3.dodil.iowithHostheader rewriting if your bucket name is in the path - Use the path-style URL — K3 requires it
- Set Cloudflare cache rules: respect origin
Cache-Control
CloudFront:
- Origin:
k3.dodil.io - Origin path:
/assets-public(so CF requests/foofrom K3 land atassets-public/foo) - Cache policy:
CachingOptimized(respects origin headers)
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 |
|---|---|---|
403 AccessDenied on anonymous GET | Bucket isn’t public | dodil k3 bucket update <name> --access-mode public |
| CSS / JS loaded but fonts blocked | CORS rule too narrow | Add GET and HEAD to allowedMethods, set exposeHeaders |
| 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 |
HTML downloads as application/octet-stream | Content-Type wasn’t set at upload | aws s3 sync infers from extension; if not, 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