Static-site deploy

Ship the static dist/ from `voltro build` to a cheap CDN (Cloudflare Pages, any S3-compatible bucket, Netlify) instead of serving it off the app server.

voltro build emits a static dist/ — pre-rendered HTML plus content-hashed assets. Serving that off your app server wastes the server on work a CDN does for pennies. voltro static ships dist/ to a cheap external host, off the app server, so you save money at the edge.

voltro static hosts                                          # list supported hosts
voltro static deploy --host cloudflare-pages --project-name my-site --spa
voltro static deploy --host s3 --bucket my-bucket --endpoint https://s3.fr-par.scw.cloud
voltro static deploy --host netlify --site-id <site-id>
voltro static deploy --host cloudflare-pages --project-name my-site --dry-run

The framework owns the policy that's easy to get wrong; the host's official tool does the transfer (so it must be on PATH and authenticated).

Static templates to start from: frontend-static-blog (SSG from a content source via getStaticPaths + islands), frontend-landing (zero-JS marketing), frontend-spa (client-rendered, --spa), and frontend-contact (a static page + a serverless form — the page deploys here, the function via voltro serverless).

Self-hosting already serves dist/

You don't NEED voltro static. When you self-host, voltro start serves dist/ end-to-end (static HTML + SSR/ISR on demand), and your baseline's reverse proxy (Caddy/nginx) can serve the files directly too. So voltro static is a pure cost-offload: push the immutable assets to a cheap CDN while your api + Postgres stay on your own box.

The split works because the browser reaches the self-hosted api over its own URL. Point the web app's api config at the api's public WebSocket endpoint:

// apps/web/app.config.ts
apis: { app: { package: '@app/api', url: 'wss://api.yourdomain.com/ws' } }

The api stays self-hosted (it must — WebSocket subscriptions + Postgres are always-on); only the static bytes move to the edge. POST /rpc forwards the session cookie exactly as the WS path, so SSR loaders + auth resolve identically.

Cache headers — voltro start sets them, and what they say

Since 0.56.0 the production web server stamps Cache-Control on everything it serves out of dist/. Before that it stamped nothing, and a browser with neither Cache-Control nor Last-Modified has no freshness information at all: it revalidated every content-hashed chunk on every visit — for the reference fixture that is ten conditional round-trips before the page is interactive, on files whose name carries their content hash.

What Header Why
/assets/index-7N08IhkU.js (content-hashed) public, max-age=31536000, immutable The hash IS the version. A new build is a new URL, so there is nothing to invalidate.
/favicon.svg, /robots.txt, anything from public/ public, max-age=3600 The URL is stable across deploys, so a long life would pin a stale file.
A pre-rendered page public, max-age=0, must-revalidate The ETag turns the revalidation into a 304 with no body.
An isr page public, max-age=0, must-revalidate Same, unless you opt into sharing it — below.

The defaults are deliberately safe for a SHARED cache: nothing is given an s-maxage, because a CDN holding an HTML page past a deploy serves the previous build's asset URLs and the framework has no purge hook to fix that. Two opt-ins, for apps that own their CDN and can purge it:

// apps/web/app.config.ts
http: {
  cache: {
    // Let a shared cache hold pre-rendered HTML for 60s.
    htmlSMaxAgeSeconds: 60,
    // Let a shared cache hold an `isr` page for its OWN `revalidate` window
    // (plus its stale-while-revalidate). Off by default: `revalidatePath`
    // purges the framework's cache and a CDN cannot see that purge, so an
    // on-demand invalidation would take up to `revalidate` seconds to reach a
    // shared copy.
    isrShared: true,
    // Both lifetimes are tunable; `immutableMaxAgeSeconds: 0` turns the
    // immutable header off entirely.
    immutableMaxAgeSeconds: 31_536_000,
    staticMaxAgeSeconds: 3_600,
  },
}

When you put the assets on a CDN with voltro static, the host's own rules apply to them instead — these headers are what the CONTAINER path says.

What the framework decides for you

  • Content-Type per file, from its extension (including the ones generic tools get wrong: .wasm, .woff2, .svg, .webmanifest, .avif).
  • Cache-Control: content-hashed assets (assets/index-a1b2c3d4.js) → public, max-age=31536000, immutable; HTML → revalidate on every request, so a redeploy is visible immediately.
  • SPA fallback (--spa): serve index.html for unknown routes — for a single-page app. Leave it off for per-route SSG output.

For hosts that read them (Cloudflare Pages, Netlify) this is emitted as _headers / _redirects. If you already ship your own _headers or _redirects in dist/, the framework leaves them untouched — your intent wins.

The render-mode gate

A pure static CDN can only serve pre-rendered HTML. So before it uploads, voltro static deploy checks the app's render-mode profile and blocks if the app isn't static-safe:

  • ssr / isr pages — these need a runtime to render per request; a CDN can't run them.
  • dynamic routes without getStaticPaths — the build emits no artifact for them, so they'd be 404 on a CDN.
voltro static deploy --host cloudflare-pages --project-name my-site
# → voltro static deploy: "." is NOT pure-static —
#     • 1 ssr page(s): need a runtime; a CDN can't render them
#     → Serve those on a runtime (`voltro start` / a container), OR add
#       getStaticPaths / set the pages to `static` / `spa`. To ship anyway
#       (ssr/isr become client-rendered), pass --allow-dynamic.

Three ways forward:

  1. Keep it static — give dynamic routes a getStaticPaths, set pages to static / spa. Then the deploy passes.
  2. Ship anyway--allow-dynamic. The ssr/isr pages become client-rendered (no server first-paint); fine if they degrade gracefully.
  3. Use a runtime — if the app genuinely needs SSR/ISR, it belongs on voltro start / a container, not a pure CDN. See self-hosting. voltro deploy plan tells you, per app, which tier it belongs to.

voltro build records the profile to dist/.voltro-build.json, so the gate (and the cloud control-plane) classify without re-scanning. Point the scan at a non-default app root with --app <dir>.

Hosts

Host --host Mechanism Auth
Cloudflare Pages cloudflare-pages wrangler pages deploy (built-in hash dedup) CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID
S3-compatible s3 aws s3 sync (two-pass cache) AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
Netlify netlify netlify deploy (SHA1 digest deploy) NETLIFY_AUTH_TOKEN

Cloudflare Pages

# one-time: wrangler pages project create my-site
voltro static deploy --host cloudflare-pages --project-name my-site --spa

The project must exist first. Uploads are incremental (wrangler dedups by hash); new deploys auto-invalidate the edge.

S3-compatible (AWS, Scaleway, R2, B2, MinIO)

One code path for every S3-compatible bucket — only the endpoint and keys change. Omit --endpoint for AWS; set it for the others.

# Scaleway Object Storage (has native static-website hosting)
AWS_ACCESS_KEY_ID=<scw-access> AWS_SECRET_ACCESS_KEY=<scw-secret> \
  voltro static deploy --host s3 --bucket my-bucket \
    --endpoint https://s3.fr-par.scw.cloud \
    --website-url https://my-bucket.s3-website.fr-par.scw.cloud

The sync runs two passes: immutable hashed assets first, then HTML with no-cache, and --delete prunes removed files. SPA fallback for a raw bucket is bucket-website / CDN configuration (and R2/B2/MinIO need a router in front entirely) — --spa warns rather than silently doing nothing here.

Netlify

voltro static deploy --host netlify --site-id <site-id> --spa

Netlify infers content types server-side and its digest-based deploy is incremental and atomic.

CI

All three host CLIs run headless with a token in the environment — drop the deploy command into your pipeline after voltro build. Use --dry-run to print the exact plan in a PR check without uploading.