Skip to Content
We are live but in Staging 🎉
Object StorageRecipesStatic 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.

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 public

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

ModeAnonymous readAnonymous writeOwner read/write
private (default)
public
customgoverned by Bucket Policygoverned 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.json

Lock allowedOrigins to your real domains in production.

3. Upload your assets

# 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 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 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.io with Host header 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 /foo from K3 land at assets-public/foo)
  • Cache policy: CachingOptimized (respects origin headers)

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
403 AccessDenied on anonymous GETBucket isn’t publicdodil k3 bucket update <name> --access-mode public
CSS / JS loaded but fonts blockedCORS rule too narrowAdd GET and HEAD to allowedMethods, set exposeHeaders
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
HTML downloads as application/octet-streamContent-Type wasn’t set at uploadaws s3 sync infers from extension; if not, pass --content-type "text/html"

See also